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
@@ -0,0 +1,1108 @@
1
+ # SFI/trajectory/degrade.py
2
+ """
3
+ SFI.trajectory.degrade
4
+ ======================
5
+
6
+ Degrade synthetic trajectories to mimic real data:
7
+ - motion blur (temporal window average)
8
+ - downsampling
9
+ - additive measurement noise
10
+ - ROI filtering (mask points outside a region)
11
+ - random data loss
12
+
13
+ Two front doors:
14
+ ----------------
15
+ 1) Dataset/Collection API (recommended for internal use)
16
+ - degrade_dataset(ds, ...)
17
+ - degrade_collection(coll, ...)
18
+
19
+ 2) Columns API (back-compat for I/O scripts)
20
+ - degrade_columns(meta, particle_idx, time_idx, state_vectors, ...)
21
+
22
+ Why two? Column flow is convenient for simple scripts and file round-trips;
23
+ dataset/collection flow keeps everything rectangular so we can blur/downsample
24
+ time-dependent extras cleanly without flatten/unflatten gymnastics.
25
+
26
+ Extras semantics
27
+ ----------------
28
+ - extras_global:
29
+ - arrays with leading shape (T, ...) are blurred/downsampled along time
30
+ like X; other entries are passed through unchanged.
31
+ - extras_local:
32
+ - arrays with shape (N, ...): per-particle constants → unchanged
33
+ - arrays with shape (T, N, ...): blurred/downsampled along time like X
34
+
35
+ Noise/ROI/data-loss are applied on the **mask** (not by deleting rows), so
36
+ tensor shapes remain intact. Flattening to columns (if needed) happens last.
37
+
38
+ Cache-only extras (auto-generated structural tables)
39
+ ----------------------------------------------------
40
+ Keys starting with ``_cache/`` are considered auto-generated structural extras
41
+ (e.g. CSR neighbor lists, stencil hyper tables). They are **not** degraded and
42
+ are **dropped** from outputs, because any degradation/context change invalidates
43
+ such cached structural objects. They can be regenerated on demand by calling
44
+ the appropriate host-side preparation routine.
45
+
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Tuple, Union
51
+
52
+ import numpy as np
53
+
54
+ from SFI.statefunc.nodes.interactions.prepare import (
55
+ CACHE_PREFIX,
56
+ is_cache_key,
57
+ purge_cache_extras,
58
+ )
59
+ from SFI.trajectory.collection import TrajectoryCollection
60
+ from SFI.trajectory.dataset import TrajectoryDataset
61
+
62
+ # -------------------------- public: dataset/collection -------------------------- #
63
+
64
+
65
+ def degrade_dataset(
66
+ ds: TrajectoryDataset,
67
+ *,
68
+ downsample: int = 1,
69
+ motion_blur: int = 0,
70
+ data_loss_fraction: float = 0.0,
71
+ noise: Union[None, float, np.ndarray] = None,
72
+ ROI: Union[None, float, np.ndarray, Callable[[np.ndarray], bool]] = None,
73
+ seed: Optional[int] = None,
74
+ ) -> TrajectoryDataset:
75
+ """
76
+ Degrade a single :class:`TrajectoryDataset`.
77
+
78
+ The function operates in tensor space; it returns a new dataset where:
79
+
80
+ - ``X`` is motion-blurred over ``motion_blur + 1`` frames and downsampled
81
+ by ``downsample``,
82
+ - the mask is AND-reduced over the blur window, then modified by ROI and
83
+ random data loss,
84
+ - ``t`` (if present) is averaged over the blur window and downsampled,
85
+ otherwise scalar ``dt`` is multiplied by ``downsample``,
86
+ - extras are processed consistently (see module docstring).
87
+
88
+ Parameters
89
+ ----------
90
+ ds
91
+ Input dataset to degrade.
92
+ downsample
93
+ Integer downsampling factor along the time axis (must be ``>= 1``).
94
+ motion_blur
95
+ Temporal averaging window size minus one. The actual blur window is
96
+ ``motion_blur + 1`` frames and must satisfy
97
+ ``0 <= motion_blur < downsample``.
98
+ data_loss_fraction
99
+ Fraction of currently valid entries to drop uniformly at random
100
+ after ROI filtering (in ``[0, 1)``).
101
+ noise
102
+ Additive Gaussian noise scale. If a float, isotropic noise with
103
+ standard deviation ``noise`` is applied. If an array, broadcast to
104
+ the state dimension.
105
+ ROI
106
+ Region-of-interest predicate or mask. Can be:
107
+
108
+ - float: radial cutoff — keeps positions with ``‖x‖₂ ≤ ROI``,
109
+ - ``(2, d)`` ndarray: axis-aligned box (row 0 = lower bound, row 1 = upper bound),
110
+ - ``Callable[[np.ndarray], bool]``: predicate evaluated on each
111
+ observed position.
112
+ seed
113
+ Optional RNG seed for the noise and data-loss generators.
114
+
115
+ Returns
116
+ -------
117
+ TrajectoryDataset
118
+ Degraded dataset with the same number of particles but fewer time
119
+ steps.
120
+ """
121
+ if downsample < 1:
122
+ raise ValueError("downsample must be >= 1")
123
+ if motion_blur < 0 or motion_blur >= downsample:
124
+ raise ValueError("motion_blur must satisfy 0 <= motion_blur < downsample.")
125
+ if not (0.0 <= data_loss_fraction < 1.0):
126
+ raise ValueError("data_loss_fraction must be in [0, 1).")
127
+
128
+ rng = np.random.default_rng(seed)
129
+
130
+ # Shapes
131
+ X = np.asarray(ds._X3d()) # (T, N, d)
132
+ M = np.asarray(ds._M2d()) # (T, N)
133
+ T, N, d = X.shape
134
+ has_dynamic = ds.dynamic_mask is not None
135
+ if has_dynamic:
136
+ M_dyn = np.asarray(ds.dynamic_mask)
137
+ if M_dyn.ndim == 1:
138
+ M_dyn = M_dyn[:, None]
139
+ else:
140
+ M_dyn = None
141
+
142
+ # Downsampling schedule
143
+ window = motion_blur + 1
144
+ keep_times = np.arange(0, max(T - motion_blur, 0), downsample, dtype=int)
145
+ K = keep_times.size
146
+ if K == 0:
147
+ # Degenerate (e.g. very small T vs blur) → return an empty dataset with copied meta/extras
148
+ return TrajectoryDataset.from_arrays(
149
+ X=np.zeros((0, N, d), dtype=X.dtype),
150
+ t=None if ds.t is None else np.zeros((0,), dtype=float),
151
+ dt=None if ds.t is not None else ds.dt,
152
+ mask=np.zeros((0, N), dtype=bool),
153
+ extras_global=purge_cache_extras(_downsample_extras_global(ds.extras_global, keep_times, window)),
154
+ extras_local=purge_cache_extras(_downsample_extras_local(ds.extras_local, keep_times, window)),
155
+ meta=_update_meta(
156
+ dict(ds.meta),
157
+ downsample,
158
+ motion_blur,
159
+ data_loss_fraction,
160
+ noise,
161
+ ROI,
162
+ seed,
163
+ ),
164
+ )
165
+
166
+ # 1) Blur/Downsample X and mask along time
167
+ X_ds = np.empty((K, N, d), dtype=X.dtype)
168
+ M_ds = np.empty((K, N), dtype=bool)
169
+ M_dyn_ds = np.empty((K, N), dtype=bool) if has_dynamic else None
170
+ for k, t0 in enumerate(keep_times):
171
+ segX = X[t0 : t0 + window] # (window, N, d)
172
+ segM = M[t0 : t0 + window] # (window, N)
173
+ with np.errstate(invalid="ignore"):
174
+ X_ds[k] = np.nanmean(np.where(segM[..., None], segX, np.nan), axis=0)
175
+ M_ds[k] = segM.all(axis=0) # require all valid within window
176
+ if has_dynamic and M_dyn_ds is not None and M_dyn is not None:
177
+ M_dyn_ds[k] = M_dyn[t0 : t0 + window].all(axis=0)
178
+
179
+ # 2) Add measurement noise to observed entries
180
+ if noise is not None:
181
+ X_ds = _add_noise_to_masked(X_ds, M_ds, rng, noise)
182
+
183
+ # 3) ROI on observed positions at blurred times
184
+ if ROI is not None:
185
+ inside = _roi_predicate(ROI, d)
186
+ # evaluate on every (k,n) that is currently valid
187
+ flat = X_ds.reshape(K * N, d)
188
+ M_flat = M_ds.reshape(K * N)
189
+ sel = np.where(M_flat)[0]
190
+ if sel.size > 0:
191
+ inside_mask = np.array([inside(flat[i]) for i in sel], dtype=bool)
192
+ M_flat[sel] = M_flat[sel] & inside_mask
193
+ if has_dynamic:
194
+ assert M_dyn_ds is not None
195
+ M_dyn_flat = M_dyn_ds.reshape(K * N)
196
+ M_dyn_flat[sel] = M_dyn_flat[sel] & inside_mask
197
+ M_dyn_ds = M_dyn_flat.reshape(K, N)
198
+ M_ds = M_flat.reshape(K, N)
199
+
200
+ # 4) Random data loss on valid entries
201
+ if data_loss_fraction > 0.0:
202
+ M_flat = M_ds.reshape(-1)
203
+ valid_idx = np.where(M_flat)[0]
204
+ keep = int(round(valid_idx.size * (1.0 - data_loss_fraction)))
205
+ if keep < valid_idx.size:
206
+ drop = rng.choice(valid_idx, size=valid_idx.size - keep, replace=False)
207
+ M_flat[drop] = False
208
+ if has_dynamic:
209
+ assert M_dyn_ds is not None
210
+ M_dyn_flat = M_dyn_ds.reshape(-1)
211
+ M_dyn_flat[drop] = False
212
+ M_dyn_ds = M_dyn_flat.reshape(K, N)
213
+ M_ds = M_flat.reshape(K, N)
214
+
215
+ # 5) Downsample 't' or scale 'dt'
216
+ if ds.t is not None:
217
+ t = np.asarray(ds.t)
218
+ t_out = np.array([np.mean(t[t0 : t0 + window]) for t0 in keep_times], dtype=float)
219
+ dt_arg = None
220
+ t_arg = t_out
221
+ else:
222
+ t_arg = None
223
+ if ds.dt is None:
224
+ dt_arg = None
225
+ else:
226
+ dta = np.asarray(ds.dt)
227
+ if dta.ndim == 0:
228
+ # Scalar dt: new step is downsample × original step.
229
+ dt_arg = float(dta) * downsample
230
+ else:
231
+ # Variable dt array: sum over each downsampled window.
232
+ dt_arg = np.array(
233
+ [float(np.sum(dta[t0 : t0 + downsample])) for t0 in keep_times],
234
+ dtype=float,
235
+ )
236
+
237
+ # 6) Downsample/blur extras
238
+ extras_g = purge_cache_extras(_downsample_extras_global(ds.extras_global, keep_times, window))
239
+ extras_l = purge_cache_extras(_downsample_extras_local(ds.extras_local, keep_times, window))
240
+
241
+ # 7) Update meta with provenance
242
+ meta2 = _update_meta(dict(ds.meta), downsample, motion_blur, data_loss_fraction, noise, ROI, seed)
243
+ if ds.dt is not None and dt_arg is not None:
244
+ meta2.setdefault("original_dt", np.asarray(ds.dt).tolist())
245
+ meta2["dt"] = dt_arg if np.ndim(dt_arg) == 0 else np.asarray(dt_arg).tolist()
246
+
247
+ return TrajectoryDataset.from_arrays(
248
+ X=X_ds,
249
+ t=t_arg,
250
+ dt=dt_arg,
251
+ mask=M_ds,
252
+ dynamic_mask=M_dyn_ds,
253
+ extras_global=extras_g,
254
+ extras_local=extras_l,
255
+ meta=meta2,
256
+ )
257
+
258
+
259
+ def degrade_collection(
260
+ coll: TrajectoryCollection,
261
+ *,
262
+ downsample: int = 1,
263
+ motion_blur: int = 0,
264
+ data_loss_fraction: float = 0.0,
265
+ noise: Union[None, float, np.ndarray] = None,
266
+ ROI: Union[None, float, np.ndarray, Callable[[np.ndarray], bool]] = None,
267
+ seed: Optional[int] = None,
268
+ reweight: Literal["pool", "keep"] = "pool",
269
+ ) -> TrajectoryCollection:
270
+ """
271
+ Degrade all datasets in a collection and optionally recompute weights.
272
+
273
+ Parameters
274
+ ----------
275
+ coll
276
+ Input collection to degrade.
277
+ downsample, motion_blur, data_loss_fraction, noise, ROI, seed
278
+ Same semantics as in :func:`degrade_dataset`.
279
+ reweight
280
+ Policy for updating collection-level weights after degradation:
281
+
282
+ - ``"pool"``: recompute weights via ``with_weights("pool")``.
283
+ - ``"keep"``: preserve the relative weights from ``coll.weights``.
284
+
285
+ Returns
286
+ -------
287
+ TrajectoryCollection
288
+ New collection whose datasets have been degraded in the same way.
289
+
290
+ Notes
291
+ -----
292
+ This function is purely functional: the input collection is not modified.
293
+ """
294
+ ds2: List[TrajectoryDataset] = [
295
+ degrade_dataset(
296
+ ds,
297
+ downsample=downsample,
298
+ motion_blur=motion_blur,
299
+ data_loss_fraction=data_loss_fraction,
300
+ noise=noise,
301
+ ROI=ROI,
302
+ seed=seed,
303
+ )
304
+ for ds in coll.datasets
305
+ ]
306
+ out = TrajectoryCollection(ds2, coll.weights)
307
+ if reweight == "pool":
308
+ return out.with_weights("pool")
309
+ if reweight == "keep":
310
+ return out.with_weights(list(map(float, coll.weights)))
311
+ raise ValueError(f"Unknown reweight policy: {reweight!r}")
312
+
313
+
314
+ # ------------------------------- helpers -------------------------------- #
315
+
316
+
317
+ def _add_noise_to_masked(
318
+ X: np.ndarray,
319
+ M: np.ndarray,
320
+ rng: np.random.Generator,
321
+ noise: Union[float, np.ndarray],
322
+ ) -> np.ndarray:
323
+ """Add Gaussian noise to X **only where M is True**."""
324
+ Y = X.copy()
325
+ T, N, d = Y.shape
326
+ if np.isscalar(noise):
327
+ # iid σ on each coordinate
328
+ eps = rng.normal(scale=float(noise), size=Y.shape)
329
+ Y[M] = Y[M] + eps[M]
330
+ return Y
331
+ noise_arr = np.asarray(noise, dtype=float)
332
+ if noise_arr.ndim == 1:
333
+ if noise_arr.shape[0] != d:
334
+ raise ValueError("Noise vector length must match state dimension d.")
335
+ eps = rng.normal(size=Y.shape) * noise_arr.reshape((1, 1, d))
336
+ Y[M] = Y[M] + eps[M]
337
+ return Y
338
+ if noise_arr.ndim == 2:
339
+ if noise_arr.shape != (d, d):
340
+ raise ValueError("Noise matrix must be (d, d).")
341
+ eps = rng.normal(size=Y.shape) @ noise_arr
342
+ Y[M] = Y[M] + eps[M]
343
+ return Y
344
+ raise ValueError("noise must be scalar, (d,), or (d,d).")
345
+
346
+
347
+ def _roi_predicate(
348
+ ROI: Union[None, float, np.ndarray, Callable[[np.ndarray], bool]], d: int
349
+ ) -> Callable:
350
+ """Build an ROI predicate in R^d.
351
+
352
+ ``float`` ROI is a radial cutoff: ``‖x‖₂ ≤ ROI``.
353
+ ``(2, d)`` array ROI is an axis-aligned box: ``lo ≤ x ≤ hi`` element-wise.
354
+ """
355
+ if ROI is None:
356
+ return lambda x: True
357
+ if np.isscalar(ROI):
358
+ r = float(ROI)
359
+ return lambda x: bool(np.linalg.norm(x) <= r)
360
+ if isinstance(ROI, np.ndarray) or hasattr(ROI, "__array__"):
361
+ arr = np.asarray(ROI, dtype=float)
362
+ if arr.shape == (2, d):
363
+ lo, hi = arr[0].copy(), arr[1].copy()
364
+ return lambda x: bool(np.all((lo <= x) & (x <= hi)))
365
+ raise ValueError(f"ndarray ROI must have shape (2, {d}), got {arr.shape}.")
366
+ if callable(ROI):
367
+ return ROI # trusted
368
+ raise ValueError("ROI must be None, scalar float, (2,d) ndarray, or callable.")
369
+
370
+
371
+ def _downsample_extras_global(eg: Dict[str, Any], keep_times: np.ndarray, window: int) -> Dict[str, Any]:
372
+ """
373
+ Blur/downsample extras_global entries with leading time dimension T.
374
+
375
+ Cache-only extras policy
376
+ ------------------------
377
+ Keys under ``_cache/`` are structural/derivable objects and are **dropped**
378
+ during degradation. They must be regenerated later from the new context.
379
+ """
380
+ out: Dict[str, Any] = {}
381
+ K = keep_times.size
382
+ for k, v in (eg or {}).items():
383
+ if is_cache_key(k, prefix=CACHE_PREFIX):
384
+ continue
385
+
386
+ # Pass through callables (e.g. FunctionExtra-unwrapped) unchanged.
387
+ if callable(v):
388
+ out[k] = v
389
+ continue
390
+
391
+ arr = np.asarray(v)
392
+ # Guard: keep_times may be empty (degenerate trajectory).
393
+ time_limit = (keep_times[-1] + window) if K > 0 else 0
394
+ if arr.ndim >= 1 and K > 0 and arr.shape[0] >= time_limit:
395
+ flat = arr.reshape((arr.shape[0], -1))
396
+ buf = np.empty((K, flat.shape[1]), dtype=float)
397
+ for i, t0 in enumerate(keep_times):
398
+ with np.errstate(invalid="ignore"):
399
+ buf[i] = np.nanmean(flat[t0 : t0 + window], axis=0)
400
+ out[k] = buf.reshape((K,) + arr.shape[1:])
401
+ elif arr.ndim >= 1 and K == 0 and arr.shape[0] > 0:
402
+ # Degenerate: return empty leading axis, preserve trailing shape.
403
+ out[k] = arr[:0]
404
+ else:
405
+ out[k] = v
406
+ return out
407
+
408
+
409
+ def _downsample_extras_local(el: Dict[str, Any], keep_times: np.ndarray, window: int) -> Dict[str, Any]:
410
+ """
411
+ Blur/downsample extras_local entries.
412
+
413
+ Cache-only extras policy
414
+ ------------------------
415
+ Keys under ``_cache/`` are structural/derivable objects and are **dropped**
416
+ during degradation.
417
+ """
418
+ out: Dict[str, Any] = {}
419
+ K = keep_times.size
420
+ for k, v in (el or {}).items():
421
+ if is_cache_key(k, prefix=CACHE_PREFIX):
422
+ continue
423
+
424
+ # Pass through callables unchanged.
425
+ if callable(v):
426
+ out[k] = v
427
+ continue
428
+
429
+ arr = np.asarray(v)
430
+ time_limit = (keep_times[-1] + window) if K > 0 else 0
431
+ if arr.ndim >= 2 and K > 0 and arr.shape[0] >= time_limit:
432
+ T_orig, N = arr.shape[0], arr.shape[1]
433
+ tail = arr.shape[2:] or ()
434
+ flat = arr.reshape((T_orig, N, -1))
435
+ buf = np.empty((K, N, flat.shape[2]), dtype=float)
436
+ for i, t0 in enumerate(keep_times):
437
+ with np.errstate(invalid="ignore"):
438
+ buf[i] = np.nanmean(flat[t0 : t0 + window], axis=0)
439
+ out[k] = buf.reshape((K, N) + tail)
440
+ elif arr.ndim >= 2 and K == 0 and arr.shape[0] > 0:
441
+ out[k] = arr[:0]
442
+ else:
443
+ out[k] = v
444
+ return out
445
+
446
+
447
+ def _update_meta(
448
+ meta: Dict[str, Any],
449
+ downsample: int,
450
+ motion_blur: int,
451
+ data_loss_fraction: float,
452
+ noise: Union[None, float, np.ndarray],
453
+ ROI: Any,
454
+ seed: Optional[int],
455
+ ) -> Dict[str, Any]:
456
+ """Record degradation provenance in meta dict (shallow copy upstream)."""
457
+ meta.update(
458
+ {
459
+ "degrade_downsample": downsample,
460
+ "degrade_motion_blur": motion_blur,
461
+ "degrade_data_loss_frac": data_loss_fraction,
462
+ "degrade_noise_spec": (
463
+ None if noise is None else float(noise) if np.isscalar(noise) else np.asarray(noise).tolist()
464
+ ),
465
+ "degrade_ROI_spec": (None if ROI is None else ("callable" if callable(ROI) else np.asarray(ROI).tolist())),
466
+ "degrade_rng_seed": seed,
467
+ }
468
+ )
469
+ return meta
470
+
471
+
472
+ # =============================================================================
473
+ # Spatial degradation for grid-based SPDE datasets
474
+ # =============================================================================
475
+
476
+
477
+ def degrade_spatial_data(
478
+ coll: TrajectoryCollection,
479
+ *,
480
+ downscale: int | Tuple[int, ...] = 2,
481
+ method: Literal["mean", "subsample"] = "mean",
482
+ blur_radius: int = 0,
483
+ data_loss_fraction: float = 0.0,
484
+ noise: Union[None, float, np.ndarray] = None,
485
+ seed: Optional[int] = None,
486
+ mask_threshold: float = 0.5,
487
+ bc: Literal["noflux", "pbc"] = "noflux",
488
+ prefix: str = "box",
489
+ order: Literal["C", "F"] = "C",
490
+ ) -> TrajectoryCollection:
491
+ """
492
+ Degrade an SPDE-style collection in *space* (blur/coarsen/pixel-loss/noise).
493
+
494
+ Assumes the standard SPDE convention:
495
+ - particle axis N is a flattened grid of shape `grid_shape`,
496
+ - state dim d is #fields per site.
497
+
498
+ ``dx`` is read from ``extras_global['{prefix}/dx']`` and updated
499
+ automatically; it does not need to be supplied here.
500
+
501
+ Also updates 'box/' box parameters and erases structural outputs starting
502
+ with _cache (regenerated on next use).
503
+ """
504
+ rng = np.random.default_rng(seed)
505
+
506
+ ds2 = []
507
+ for ds in coll.datasets:
508
+ ds2.append(
509
+ degrade_spatial_dataset(
510
+ ds,
511
+ downscale=downscale,
512
+ method=method,
513
+ blur_radius=blur_radius,
514
+ data_loss_fraction=data_loss_fraction,
515
+ noise=noise,
516
+ rng=rng,
517
+ mask_threshold=mask_threshold,
518
+ bc=bc,
519
+ prefix=prefix,
520
+ order=order,
521
+ )
522
+ )
523
+
524
+ out = TrajectoryCollection(ds2, coll.weights)
525
+ return out.with_weights(list(map(float, coll.weights)))
526
+
527
+
528
+ def degrade_spatial_dataset(
529
+ ds: TrajectoryDataset,
530
+ *,
531
+ downscale: int | Tuple[int, ...] = 1,
532
+ method: Literal["mean", "subsample"] = "mean",
533
+ blur_radius: int = 0,
534
+ data_loss_fraction: float = 0.0,
535
+ noise: Union[None, float, np.ndarray] = None,
536
+ rng: np.random.Generator,
537
+ mask_threshold: float = 0.5,
538
+ bc: Literal["noflux", "pbc"] = "noflux",
539
+ prefix: str = "box",
540
+ order: Literal["C", "F"] = "C",
541
+ ) -> TrajectoryDataset:
542
+ """Spatial degradation of a single SPDE-style dataset.
543
+
544
+ Key invariants ensured by this routine
545
+ --------------------------------------
546
+ 1) The flattening convention is preserved (``order="C"`` or ``"F"``).
547
+ 2) Box metadata (grid_shape, dx) is updated consistently after coarsening.
548
+ 3) Any prepared structural stencil payload is dropped so it is rebuilt for the new grid.
549
+ 4) Mask handling is conservative: a coarse cell is valid only if enough fine pixels are valid.
550
+ """
551
+ # ---- materialize tensors ----
552
+ X = np.asarray(ds._X3d()) # (T, N, d)
553
+ M = np.asarray(ds._M2d()) # (T, N)
554
+ T, N, d = X.shape
555
+
556
+ # Fetch grid shape
557
+ gs = (ds.extras_global or {}).get(f"{prefix}/grid_shape", None)
558
+ if gs is None:
559
+ raise ValueError(
560
+ "degrade_spatial_dataset: grid_shape not provided and could not be "
561
+ f"inferred from extras_global['{prefix}/grid_shape']."
562
+ )
563
+ grid_shape = tuple(int(v) for v in np.asarray(gs).tolist())
564
+
565
+ ndim = len(grid_shape)
566
+ if int(np.prod(grid_shape, dtype=np.int64)) != N:
567
+ raise ValueError(f"grid_shape={grid_shape} incompatible with N={N}.")
568
+
569
+ # ---- downscale factors ----
570
+ if isinstance(downscale, int):
571
+ fac = (int(downscale),) * ndim
572
+ else:
573
+ fac = tuple(int(v) for v in downscale)
574
+ if len(fac) != ndim:
575
+ raise ValueError(f"downscale must have length {ndim} (got {len(fac)})")
576
+ if any(v < 1 for v in fac):
577
+ raise ValueError("downscale factors must be >= 1")
578
+
579
+ # ---- infer dx (needed to update metadata after coarsening) ----
580
+ dx_ex = (ds.extras_global or {}).get(f"{prefix}/dx", None)
581
+ dx_in = _normalize_dx(dx_ex, ndim=ndim)
582
+
583
+ # ---- reshape to grid (respect flattening convention!) ----
584
+ # IMPORTANT: if you ever use order="F" anywhere in stencil construction, you must
585
+ # preserve it here, otherwise the coarse field will be indexed differently than
586
+ # the (re)built neighbor tables.
587
+ Xg = X.reshape((T,) + grid_shape + (d,), order=order)
588
+ Mg = M.reshape((T,) + grid_shape, order=order)
589
+
590
+ # ---- optional blur (masked box blur; ignores missing pixels) ----
591
+ if blur_radius > 0:
592
+ Xg, Mg = _box_blur_nd(Xg, Mg, radius=int(blur_radius), bc=bc)
593
+
594
+ # ---- downscale ----
595
+ Xc, Mc = _downscale_nd(
596
+ Xg,
597
+ Mg,
598
+ factors=fac,
599
+ method=method,
600
+ mask_threshold=mask_threshold,
601
+ )
602
+ out_shape = tuple(int(s) for s in Xc.shape[1:-1])
603
+ N2 = int(np.prod(out_shape, dtype=np.int64))
604
+
605
+ # ---- flatten back (same order) ----
606
+ Xc2 = Xc.reshape((T, N2, d), order=order)
607
+ Mc2 = Mc.reshape((T, N2), order=order)
608
+
609
+ # ---- optional measurement noise on observed pixels ----
610
+ if noise is not None:
611
+ Xc2 = _add_noise_to_masked(Xc2, Mc2, rng, noise)
612
+
613
+ # ---- random pixel loss (on the coarse grid) ----
614
+ if data_loss_fraction > 0.0:
615
+ flat = Mc2.reshape(-1)
616
+ valid_idx = np.where(flat)[0]
617
+ keep = int(round(valid_idx.size * (1.0 - data_loss_fraction)))
618
+ if keep < valid_idx.size:
619
+ drop = rng.choice(valid_idx, size=valid_idx.size - keep, replace=False)
620
+ flat[drop] = False
621
+ Mc2 = flat.reshape((T, N2))
622
+
623
+ # ---- extras: purge invalid structural payloads, then update box metadata ----
624
+ extras_g = dict(ds.extras_global or {})
625
+
626
+ # Update dx for the coarse grid: dx_out = dx_in * factor
627
+ dx_out = None
628
+ if dx_in is not None:
629
+ dx_out = tuple(float(dx_in[ax]) * float(fac[ax]) for ax in range(ndim))
630
+
631
+ # Keep the box extras in sync with the new grid.
632
+ from SFI.bases.spde import square_grid_extras
633
+
634
+ extras_g.update(
635
+ square_grid_extras(
636
+ grid_shape=out_shape,
637
+ dx=(1.0 if dx_out is None else dx_out),
638
+ prefix=prefix,
639
+ )
640
+ )
641
+
642
+ # ---- extras_local: downscale per-site fields if present ----
643
+ extras_l = None
644
+ if ds.extras_local:
645
+ extras_l = _downscale_extras_local_grid(
646
+ extras_local=dict(ds.extras_local),
647
+ grid_shape=grid_shape,
648
+ factors=fac,
649
+ method=method,
650
+ # If your helper supports it, pass Mg/mask_threshold so it can
651
+ # handle invalid blocks consistently. Otherwise omit.
652
+ mask=Mg,
653
+ mask_threshold=mask_threshold,
654
+ order=order,
655
+ )
656
+
657
+ # ---- meta ----
658
+ meta2 = dict(ds.meta)
659
+ meta2.update(
660
+ dict(
661
+ degrade_spatial_downscale=fac,
662
+ degrade_spatial_method=method,
663
+ degrade_spatial_blur_radius=int(blur_radius),
664
+ degrade_spatial_data_loss_frac=float(data_loss_fraction),
665
+ degrade_spatial_noise_spec=(
666
+ None if noise is None else float(noise) if np.isscalar(noise) else np.asarray(noise).tolist()
667
+ ),
668
+ degrade_spatial_bc=bc,
669
+ degrade_spatial_order=order,
670
+ )
671
+ )
672
+
673
+ return TrajectoryDataset.from_arrays(
674
+ X=Xc2,
675
+ t=ds.t,
676
+ dt=ds.dt,
677
+ mask=Mc2,
678
+ extras_global=extras_g,
679
+ extras_local=extras_l,
680
+ meta=meta2,
681
+ )
682
+
683
+
684
+ def _box_filter_1d(
685
+ X: np.ndarray,
686
+ M: np.ndarray,
687
+ *,
688
+ radius: int,
689
+ axis: int,
690
+ bc: Literal["pbc", "noflux", "drop"] = "noflux",
691
+ ) -> tuple[np.ndarray, np.ndarray]:
692
+ """
693
+ 1D **masked** box filter along one spatial axis.
694
+
695
+ This is the core primitive used by `_box_blur_nd` (separable N-D blur).
696
+
697
+ Parameters
698
+ ----------
699
+ X
700
+ Array of shape ``(..., L, C)`` *along the chosen axis*, where:
701
+ - L is the axis length being blurred,
702
+ - C is the "value/channel" axis (typically state dimension, or a flattened tail).
703
+ In your SPDE pipeline this will typically be ``(T, *grid, d)`` reshaped/moved.
704
+ M
705
+ Boolean mask of shape ``(..., L)`` aligned with `X` (same leading axes, no channel axis).
706
+ Masked entries (False) are ignored in the average.
707
+ radius
708
+ Blur radius r. Window size is ``W = 2r + 1``.
709
+ axis
710
+ Axis index in `X` to blur over (same logical axis in `M`).
711
+ In your grid convention: time is axis 0, grid axes are 1..ndim, channel is last.
712
+ bc
713
+ Boundary condition:
714
+ - "pbc": periodic wrapping
715
+ - "noflux": clamp to edge (replicate boundary samples)
716
+ - "drop": treat out-of-bounds as missing (do not contribute)
717
+
718
+ Returns
719
+ -------
720
+ Xf, Mf
721
+ Filtered values and updated mask:
722
+ - `Mf` is True where at least one valid sample contributed to the window.
723
+ - Where `Mf` is False, `Xf` is set to 0 (by convention).
724
+ """
725
+ r = int(radius)
726
+ if r <= 0:
727
+ return X, M
728
+
729
+ # We implement the blur by:
730
+ # 1) moving the target axis next to the channel axis,
731
+ # 2) padding according to `bc`,
732
+ # 3) sliding-window sums via cumsum (O(L), not O(L*W)).
733
+ #
734
+ # This is *masked* averaging:
735
+ # num = sum( X * M )
736
+ # den = sum( M )
737
+ # X_out = num / den where den>0
738
+ # M_out = den>0
739
+
740
+ # Move blur axis to be the second-to-last axis (right before channels).
741
+ # After this: X1 has shape (..., L, C), M1 has shape (..., L).
742
+ X1 = np.moveaxis(X, axis, -2)
743
+ M1 = np.moveaxis(M, axis, -1)
744
+
745
+ if X1.shape[-2] != M1.shape[-1]:
746
+ raise ValueError("X and M incompatible along blur axis.")
747
+
748
+ W = 2 * r + 1
749
+
750
+ # --- pad along L according to bc ---
751
+ if bc == "pbc":
752
+ # Wrap: [..., L, C] -> [..., L+2r, C]
753
+ Xp = np.concatenate([X1[..., -r:, :], X1, X1[..., :r, :]], axis=-2)
754
+ Mp = np.concatenate([M1[..., -r:], M1, M1[..., :r]], axis=-1)
755
+ elif bc == "noflux":
756
+ # Clamp: replicate edge values.
757
+ padX = [(0, 0)] * X1.ndim
758
+ padM = [(0, 0)] * M1.ndim
759
+ padX[-2] = (r, r)
760
+ padM[-1] = (r, r)
761
+ Xp = np.pad(X1, pad_width=padX, mode="edge")
762
+ Mp = np.pad(M1, pad_width=padM, mode="edge")
763
+ elif bc == "drop":
764
+ # Out-of-bounds are invalid: pad mask with False, values arbitrary (0).
765
+ padX = [(0, 0)] * X1.ndim
766
+ padM = [(0, 0)] * M1.ndim
767
+ padX[-2] = (r, r)
768
+ padM[-1] = (r, r)
769
+ Xp = np.pad(X1, pad_width=padX, mode="constant", constant_values=0.0)
770
+ Mp = np.pad(M1, pad_width=padM, mode="constant", constant_values=False)
771
+ else:
772
+ raise ValueError(f"Unknown bc={bc!r}")
773
+
774
+ # --- masked sliding sums with cumsum ---
775
+ # Numerator: sum(X * M) over the window; denominator: sum(M) over the window.
776
+ # We prepend a zero so "sum over [i, i+W)" becomes csum[i+W] - csum[i].
777
+ Xw = Xp * Mp[..., None] # broadcast mask onto channels
778
+ num_csum = np.cumsum(Xw, axis=-2)
779
+ den_csum = np.cumsum(Mp.astype(np.int64), axis=-1)
780
+
781
+ # Prepend zeros along the summed axis to use the classic sliding window diff.
782
+ num0 = np.concatenate([np.zeros_like(num_csum[..., :1, :]), num_csum], axis=-2)
783
+ den0 = np.concatenate([np.zeros_like(den_csum[..., :1]), den_csum], axis=-1)
784
+
785
+ num = num0[..., W:, :] - num0[..., :-W, :] # (..., L, C)
786
+ den = den0[..., W:] - den0[..., :-W] # (..., L)
787
+
788
+ # Average where den>0; else output 0 and mask False.
789
+ Xf = np.divide(
790
+ num,
791
+ den[..., None],
792
+ out=np.zeros_like(num, dtype=X1.dtype),
793
+ where=(den[..., None] > 0),
794
+ )
795
+ Mf = den > 0
796
+
797
+ # Move axes back to original positions.
798
+ X_out = np.moveaxis(Xf, -2, axis)
799
+ M_out = np.moveaxis(Mf, -1, axis)
800
+ return X_out, M_out
801
+
802
+
803
+ def _box_blur_nd(
804
+ Xg: np.ndarray,
805
+ Mg: np.ndarray,
806
+ *,
807
+ radius: int,
808
+ bc: Literal["pbc", "noflux", "drop"] = "noflux",
809
+ ) -> tuple[np.ndarray, np.ndarray]:
810
+ """
811
+ Separable N-D masked box blur over **all grid axes**.
812
+
813
+ Parameters
814
+ ----------
815
+ Xg
816
+ Grid data of shape ``(T, *grid_shape, d)``.
817
+ Mg
818
+ Grid mask of shape ``(T, *grid_shape)``.
819
+ radius
820
+ Blur radius (same on every grid axis). Window size per axis: ``2r+1``.
821
+ bc
822
+ Boundary handling; passed to `_box_filter_1d`.
823
+
824
+ Notes
825
+ -----
826
+ This is implemented as consecutive 1D masked box filters along each grid axis.
827
+ That yields an N-D box kernel because the box is separable.
828
+
829
+ The mask is updated at each 1D pass:
830
+ a voxel survives if it had **at least one** valid contributor in the window
831
+ along that axis (and recursively along all axes after all passes).
832
+ """
833
+ r = int(radius)
834
+ if r <= 0:
835
+ return Xg, Mg
836
+
837
+ X = Xg
838
+ M = Mg
839
+ # Convention assumed by your SPDE code: time axis 0, grid axes 1..ndim, channel last.
840
+ ndim = X.ndim - 2
841
+ for ax in range(1, 1 + ndim):
842
+ X, M = _box_filter_1d(X, M, radius=r, axis=ax, bc=bc)
843
+ return X, M
844
+
845
+
846
+ def _downscale_nd(
847
+ Xg: np.ndarray,
848
+ Mg: np.ndarray,
849
+ *,
850
+ factors: tuple[int, ...],
851
+ method: Literal["average", "mean", "subsample", "nearest"] = "average",
852
+ mask_threshold: float = 0.0,
853
+ ) -> tuple[np.ndarray, np.ndarray]:
854
+ """
855
+ Downscale a masked grid by integer block factors.
856
+
857
+ Parameters
858
+ ----------
859
+ Xg
860
+ Shape ``(T, *grid_shape, d)``.
861
+ Mg
862
+ Shape ``(T, *grid_shape)``.
863
+ factors
864
+ Integer factors per grid axis, e.g. (2,2) maps (Nx,Ny)->(Nx/2,Ny/2).
865
+ method
866
+ - "average"/"mean": masked block-average (recommended)
867
+ - "subsample"/"nearest": pick the [0,0,...] corner of each block
868
+ mask_threshold
869
+ Fraction in [0,1]. A coarse cell is marked valid iff the number of valid
870
+ fine pixels in the block is at least:
871
+ ceil(mask_threshold * prod(factors))
872
+ Special case: if mask_threshold <= 0, we only require at least 1 valid pixel.
873
+
874
+ Returns
875
+ -------
876
+ Xc, Mc
877
+ Coarse grid values and mask, shapes ``(T, *coarse_shape, d)``, ``(T, *coarse_shape)``.
878
+
879
+ Notes
880
+ -----
881
+ - If a grid axis is not divisible by its factor, we **crop** the high end.
882
+ (This avoids inventing boundary conventions at the degradation level.)
883
+ - For "average": values are averaged over **valid** pixels only.
884
+ Blocks with no valid pixels output X=0 and mask=False.
885
+ """
886
+ if Xg.ndim < 3:
887
+ raise ValueError("Xg must have shape (T, *grid, d).")
888
+ if Mg.shape != Xg.shape[:-1]:
889
+ raise ValueError("Mg must have shape (T, *grid) aligned with Xg.")
890
+ ndim = Xg.ndim - 2
891
+ if len(factors) != ndim:
892
+ raise ValueError(f"factors must have length {ndim} (got {len(factors)})")
893
+ fac = tuple(int(f) for f in factors)
894
+ if any(f < 1 for f in fac):
895
+ raise ValueError("All downscale factors must be >= 1.")
896
+
897
+ T = Xg.shape[0]
898
+ grid_shape = Xg.shape[1:-1]
899
+ d = Xg.shape[-1]
900
+
901
+ coarse_shape = tuple(int(gs // f) for gs, f in zip(grid_shape, fac))
902
+ crop_shape = tuple(cs * f for cs, f in zip(coarse_shape, fac))
903
+
904
+ # Crop fine grid so each axis is divisible by its factor.
905
+ slicer = (slice(None),) + tuple(slice(0, Lc) for Lc in crop_shape) + (slice(None),)
906
+ X = Xg[slicer]
907
+ M = Mg[(slice(None),) + tuple(slice(0, Lc) for Lc in crop_shape)]
908
+
909
+ # Reshape into interleaved (coarse_axis, block_axis) pairs:
910
+ # (T, n1, f1, n2, f2, ..., d)
911
+ new_shape_X = [T]
912
+ new_shape_M = [T]
913
+ for cs, f in zip(coarse_shape, fac):
914
+ new_shape_X.extend([cs, f])
915
+ new_shape_M.extend([cs, f])
916
+ new_shape_X.append(d)
917
+
918
+ Xr = X.reshape(tuple(new_shape_X))
919
+ Mr = M.reshape(tuple(new_shape_M))
920
+
921
+ # Block axes are the "f" axes: indices 2,4,6,... in Mr; in Xr they are 2,4,6,... as well.
922
+ block_axes = tuple(1 + 2 * i + 1 for i in range(ndim)) # (2,4,6,... in 1-based count) -> actual indices
923
+ # Example ndim=2: shape (T, n1, f1, n2, f2, d) -> block_axes=(2,4)
924
+
925
+ # Count valid pixels per block.
926
+ den = Mr.sum(axis=block_axes) # (T, *coarse_shape)
927
+
928
+ # Decide coarse mask according to threshold.
929
+ block_size = int(np.prod(fac, dtype=np.int64))
930
+ if mask_threshold <= 0.0:
931
+ required = 1
932
+ else:
933
+ required = int(np.ceil(float(mask_threshold) * block_size))
934
+ required = max(1, min(required, block_size))
935
+ Mc = den >= required
936
+
937
+ meth = method.lower()
938
+ if meth in ("average", "mean"):
939
+ # Masked sum over blocks.
940
+ num = (Xr * Mr[..., None]).sum(axis=block_axes) # (T, *coarse_shape, d)
941
+ Xc = np.divide(
942
+ num,
943
+ den[..., None],
944
+ out=np.zeros_like(num, dtype=Xg.dtype),
945
+ where=(den[..., None] > 0),
946
+ )
947
+ # Ensure blocks deemed invalid are neutral (for downstream ops expecting mask-consistency).
948
+ Xc = np.where(Mc[..., None], Xc, 0.0)
949
+ return Xc, Mc
950
+
951
+ if meth in ("subsample", "nearest"):
952
+ # Take the [0,0,...] entry of each block.
953
+ # Build an index tuple selecting 0 on each block axis.
954
+ idx = [slice(None)]
955
+ for _ in range(ndim):
956
+ idx.append(slice(None)) # coarse axis
957
+ idx.append(0) # block axis
958
+ idx.append(slice(None)) # channels
959
+ Xc = Xr[tuple(idx)]
960
+ # For mask, do the same slicing.
961
+ idxM = [slice(None)]
962
+ for _ in range(ndim):
963
+ idxM.append(slice(None))
964
+ idxM.append(0)
965
+ Mc0 = Mr[tuple(idxM)]
966
+ # Still respect the threshold rule if requested.
967
+ Mc = Mc & Mc0
968
+ Xc = np.where(Mc[..., None], Xc, 0.0)
969
+ return Xc, Mc
970
+
971
+ raise ValueError(f"Unknown downscale method: {method!r}")
972
+
973
+
974
+ def _downscale_extras_local_grid(
975
+ extras_local: Optional[Mapping[str, Any]],
976
+ *,
977
+ grid_shape: tuple[int, ...],
978
+ factors: tuple[int, ...],
979
+ method: Literal["average", "mean", "subsample", "nearest"] = "average",
980
+ mask: Optional[np.ndarray] = None,
981
+ mask_threshold: float = 0.0,
982
+ ) -> Dict[str, Any]:
983
+ """
984
+ Downscale **flattened per-site** extras alongside a grid downscale.
985
+
986
+ This is meant for datasets where "particles" are actually grid sites
987
+ (N = prod(grid_shape)), and `extras_local` may store per-site arrays like:
988
+ - coordinates (x,y) per pixel,
989
+ - edge vectors per pixel,
990
+ - per-pixel calibration factors, etc.
991
+
992
+ Heuristics (non-destructive):
993
+ -----------------------------
994
+ - If an entry looks like per-site constants with shape (N, ...),
995
+ it is reshaped to (*grid_shape, ...), downscaled, then re-flattened.
996
+ - If an entry looks like time-dependent per-site extras with shape (T, N, ...),
997
+ it is reshaped to (T, *grid_shape, ...), downscaled, then re-flattened.
998
+ - Anything else is passed through unchanged.
999
+
1000
+ Parameters
1001
+ ----------
1002
+ extras_local
1003
+ Dict-like mapping of extras.
1004
+ grid_shape
1005
+ Fine grid shape used to interpret the flattened N axis.
1006
+ factors
1007
+ Integer downscale factors per grid axis.
1008
+ method, mask_threshold
1009
+ Passed to `_downscale_nd`.
1010
+ mask
1011
+ Optional fine-grid mask to use for time-dependent extras, either:
1012
+ - shape (T, N) / (T, *grid_shape), or
1013
+ - shape (T, *grid_shape).
1014
+ If omitted, extras are downscaled as if fully observed.
1015
+
1016
+ Returns
1017
+ -------
1018
+ dict
1019
+ New extras_local dictionary with downscaled per-site entries.
1020
+ """
1021
+ out: Dict[str, Any] = dict(extras_local or {})
1022
+ if not extras_local:
1023
+ return out
1024
+
1025
+ grid_shape = tuple(int(s) for s in grid_shape)
1026
+ ndim = len(grid_shape)
1027
+ N = int(np.prod(grid_shape, dtype=np.int64))
1028
+
1029
+ # Normalize mask to (T, *grid) if provided.
1030
+ Mg = None
1031
+ if mask is not None:
1032
+ Mm = np.asarray(mask)
1033
+ if Mm.ndim == 2 and Mm.shape[1] == N:
1034
+ # (T, N) flattened -> (T, *grid)
1035
+ Tm = Mm.shape[0]
1036
+ Mg = Mm.reshape((Tm,) + grid_shape)
1037
+ elif Mm.ndim == 1 and Mm.shape[0] == N:
1038
+ Mg = Mm.reshape((1,) + grid_shape)
1039
+ elif Mm.ndim == 1 + ndim:
1040
+ Mg = Mm
1041
+ else:
1042
+ raise ValueError(f"mask has incompatible shape {Mm.shape} for grid_shape={grid_shape} (N={N}).")
1043
+
1044
+ for k, v in list(extras_local.items()):
1045
+ arr = np.asarray(v)
1046
+ # Case A: per-site constants (N, ...)
1047
+ if arr.ndim >= 1 and arr.shape[0] == N:
1048
+ tail = arr.shape[1:]
1049
+ # Represent as (T=1, *grid, C) by flattening tail into channels.
1050
+ C = int(np.prod(tail, dtype=np.int64)) if tail else 1
1051
+ Xg = arr.reshape(grid_shape + tail).reshape((1,) + grid_shape + (C,))
1052
+ Mg_use = np.ones((1,) + grid_shape, dtype=bool)
1053
+ Xc, Mc = _downscale_nd(
1054
+ Xg,
1055
+ Mg_use,
1056
+ factors=factors,
1057
+ method=method,
1058
+ mask_threshold=mask_threshold,
1059
+ )
1060
+ # Flatten back: (*coarse, C) -> (N2, tail)
1061
+ coarse_shape = Xc.shape[1:-1]
1062
+ N2 = int(np.prod(coarse_shape, dtype=np.int64))
1063
+ out[k] = Xc.reshape((1, N2, C))[0].reshape((N2,) + tail)
1064
+ continue
1065
+
1066
+ # Case B: time-dependent per-site extras (T, N, ...)
1067
+ if arr.ndim >= 2 and arr.shape[1] == N:
1068
+ Tm = arr.shape[0]
1069
+ tail = arr.shape[2:]
1070
+ C = int(np.prod(tail, dtype=np.int64)) if tail else 1
1071
+ Xg = arr.reshape((Tm,) + grid_shape + tail).reshape((Tm,) + grid_shape + (C,))
1072
+ Mg_use = Mg
1073
+ if Mg_use is None:
1074
+ Mg_use = np.ones((Tm,) + grid_shape, dtype=bool)
1075
+ elif Mg_use.shape[0] == 1 and Tm != 1:
1076
+ # Broadcast a static mask across time if needed.
1077
+ Mg_use = np.broadcast_to(Mg_use, (Tm,) + grid_shape)
1078
+ Xc, Mc = _downscale_nd(
1079
+ Xg,
1080
+ Mg_use,
1081
+ factors=factors,
1082
+ method=method,
1083
+ mask_threshold=mask_threshold,
1084
+ )
1085
+ coarse_shape = Xc.shape[1:-1]
1086
+ N2 = int(np.prod(coarse_shape, dtype=np.int64))
1087
+ out[k] = Xc.reshape((Tm, N2, C)).reshape((Tm, N2) + tail)
1088
+ continue
1089
+
1090
+ # Otherwise: keep as-is.
1091
+ out[k] = v
1092
+
1093
+ return out
1094
+
1095
+
1096
+ def _normalize_dx(dx, *, ndim: int) -> tuple[float, ...] | None:
1097
+ """Normalize dx to a length-ndim tuple of floats (or None)."""
1098
+ if dx is None:
1099
+ return None
1100
+ arr = np.asarray(dx)
1101
+ if arr.ndim == 0:
1102
+ return (float(arr),) * ndim
1103
+ vals = tuple(float(v) for v in arr.reshape(-1).tolist())
1104
+ if len(vals) == 1 and ndim > 1:
1105
+ return (vals[0],) * ndim
1106
+ if len(vals) != ndim:
1107
+ raise ValueError(f"dx must be scalar or length {ndim} (got {len(vals)})")
1108
+ return vals