StochasticForceInference 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|