pivtools 0.1.3__cp311-cp311-win_amd64.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 (127) hide show
  1. pivtools-0.1.3.dist-info/METADATA +222 -0
  2. pivtools-0.1.3.dist-info/RECORD +127 -0
  3. pivtools-0.1.3.dist-info/WHEEL +5 -0
  4. pivtools-0.1.3.dist-info/entry_points.txt +3 -0
  5. pivtools-0.1.3.dist-info/top_level.txt +3 -0
  6. pivtools_cli/__init__.py +5 -0
  7. pivtools_cli/_build_marker.c +25 -0
  8. pivtools_cli/_build_marker.cp311-win_amd64.pyd +0 -0
  9. pivtools_cli/cli.py +225 -0
  10. pivtools_cli/example.py +139 -0
  11. pivtools_cli/lib/PIV_2d_cross_correlate.c +334 -0
  12. pivtools_cli/lib/PIV_2d_cross_correlate.h +22 -0
  13. pivtools_cli/lib/common.h +36 -0
  14. pivtools_cli/lib/interp2custom.c +146 -0
  15. pivtools_cli/lib/interp2custom.h +48 -0
  16. pivtools_cli/lib/peak_locate_gsl.c +711 -0
  17. pivtools_cli/lib/peak_locate_gsl.h +40 -0
  18. pivtools_cli/lib/peak_locate_gsl_print.c +736 -0
  19. pivtools_cli/lib/peak_locate_lm.c +751 -0
  20. pivtools_cli/lib/peak_locate_lm.h +27 -0
  21. pivtools_cli/lib/xcorr.c +342 -0
  22. pivtools_cli/lib/xcorr.h +31 -0
  23. pivtools_cli/lib/xcorr_cache.c +78 -0
  24. pivtools_cli/lib/xcorr_cache.h +26 -0
  25. pivtools_cli/piv/interp2custom/interp2custom.py +69 -0
  26. pivtools_cli/piv/piv.py +240 -0
  27. pivtools_cli/piv/piv_backend/base.py +825 -0
  28. pivtools_cli/piv/piv_backend/cpu_instantaneous.py +1005 -0
  29. pivtools_cli/piv/piv_backend/factory.py +28 -0
  30. pivtools_cli/piv/piv_backend/gpu_instantaneous.py +15 -0
  31. pivtools_cli/piv/piv_backend/infilling.py +445 -0
  32. pivtools_cli/piv/piv_backend/outlier_detection.py +306 -0
  33. pivtools_cli/piv/piv_backend/profile_cpu_instantaneous.py +230 -0
  34. pivtools_cli/piv/piv_result.py +40 -0
  35. pivtools_cli/piv/save_results.py +342 -0
  36. pivtools_cli/piv_cluster/cluster.py +108 -0
  37. pivtools_cli/preprocessing/filters.py +399 -0
  38. pivtools_cli/preprocessing/preprocess.py +79 -0
  39. pivtools_cli/tests/helpers.py +107 -0
  40. pivtools_cli/tests/instantaneous_piv/test_piv_integration.py +167 -0
  41. pivtools_cli/tests/instantaneous_piv/test_piv_integration_multi.py +553 -0
  42. pivtools_cli/tests/preprocessing/test_filters.py +41 -0
  43. pivtools_core/__init__.py +5 -0
  44. pivtools_core/config.py +703 -0
  45. pivtools_core/config.yaml +135 -0
  46. pivtools_core/image_handling/__init__.py +0 -0
  47. pivtools_core/image_handling/load_images.py +464 -0
  48. pivtools_core/image_handling/readers/__init__.py +53 -0
  49. pivtools_core/image_handling/readers/generic_readers.py +50 -0
  50. pivtools_core/image_handling/readers/lavision_reader.py +190 -0
  51. pivtools_core/image_handling/readers/registry.py +24 -0
  52. pivtools_core/paths.py +49 -0
  53. pivtools_core/vector_loading.py +248 -0
  54. pivtools_gui/__init__.py +3 -0
  55. pivtools_gui/app.py +687 -0
  56. pivtools_gui/calibration/__init__.py +0 -0
  57. pivtools_gui/calibration/app/__init__.py +0 -0
  58. pivtools_gui/calibration/app/views.py +1186 -0
  59. pivtools_gui/calibration/calibration_planar/planar_calibration_production.py +570 -0
  60. pivtools_gui/calibration/vector_calibration_production.py +544 -0
  61. pivtools_gui/config.py +703 -0
  62. pivtools_gui/image_handling/__init__.py +0 -0
  63. pivtools_gui/image_handling/load_images.py +464 -0
  64. pivtools_gui/image_handling/readers/__init__.py +53 -0
  65. pivtools_gui/image_handling/readers/generic_readers.py +50 -0
  66. pivtools_gui/image_handling/readers/lavision_reader.py +190 -0
  67. pivtools_gui/image_handling/readers/registry.py +24 -0
  68. pivtools_gui/masking/__init__.py +0 -0
  69. pivtools_gui/masking/app/__init__.py +0 -0
  70. pivtools_gui/masking/app/views.py +123 -0
  71. pivtools_gui/paths.py +49 -0
  72. pivtools_gui/piv_runner.py +261 -0
  73. pivtools_gui/pivtools.py +58 -0
  74. pivtools_gui/plotting/__init__.py +0 -0
  75. pivtools_gui/plotting/app/__init__.py +0 -0
  76. pivtools_gui/plotting/app/views.py +1671 -0
  77. pivtools_gui/plotting/plot_maker.py +220 -0
  78. pivtools_gui/post_processing/POD/__init__.py +0 -0
  79. pivtools_gui/post_processing/POD/app/__init__.py +0 -0
  80. pivtools_gui/post_processing/POD/app/views.py +647 -0
  81. pivtools_gui/post_processing/POD/pod_decompose.py +979 -0
  82. pivtools_gui/post_processing/POD/views.py +1096 -0
  83. pivtools_gui/post_processing/__init__.py +0 -0
  84. pivtools_gui/static/404.html +1 -0
  85. pivtools_gui/static/_next/static/chunks/117-d5793c8e79de5511.js +2 -0
  86. pivtools_gui/static/_next/static/chunks/484-cfa8b9348ce4f00e.js +1 -0
  87. pivtools_gui/static/_next/static/chunks/869-320a6b9bdafbb6d3.js +1 -0
  88. pivtools_gui/static/_next/static/chunks/app/_not-found/page-12f067ceb7415e55.js +1 -0
  89. pivtools_gui/static/_next/static/chunks/app/layout-b907d5f31ac82e9d.js +1 -0
  90. pivtools_gui/static/_next/static/chunks/app/page-334cc4e8444cde2f.js +1 -0
  91. pivtools_gui/static/_next/static/chunks/fd9d1056-ad15f396ddf9b7e5.js +1 -0
  92. pivtools_gui/static/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
  93. pivtools_gui/static/_next/static/chunks/main-a1b3ced4d5f6d998.js +1 -0
  94. pivtools_gui/static/_next/static/chunks/main-app-8a63c6f5e7baee11.js +1 -0
  95. pivtools_gui/static/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
  96. pivtools_gui/static/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
  97. pivtools_gui/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. pivtools_gui/static/_next/static/chunks/webpack-4a8ca7c99e9bb3d8.js +1 -0
  99. pivtools_gui/static/_next/static/css/7d3f2337d7ea12a5.css +3 -0
  100. pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_buildManifest.js +1 -0
  101. pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_ssgManifest.js +1 -0
  102. pivtools_gui/static/file.svg +1 -0
  103. pivtools_gui/static/globe.svg +1 -0
  104. pivtools_gui/static/grid.svg +8 -0
  105. pivtools_gui/static/index.html +1 -0
  106. pivtools_gui/static/index.txt +8 -0
  107. pivtools_gui/static/next.svg +1 -0
  108. pivtools_gui/static/vercel.svg +1 -0
  109. pivtools_gui/static/window.svg +1 -0
  110. pivtools_gui/stereo_reconstruction/__init__.py +0 -0
  111. pivtools_gui/stereo_reconstruction/app/__init__.py +0 -0
  112. pivtools_gui/stereo_reconstruction/app/views.py +1985 -0
  113. pivtools_gui/stereo_reconstruction/stereo_calibration_production.py +606 -0
  114. pivtools_gui/stereo_reconstruction/stereo_reconstruction_production.py +544 -0
  115. pivtools_gui/utils.py +63 -0
  116. pivtools_gui/vector_loading.py +248 -0
  117. pivtools_gui/vector_merging/__init__.py +1 -0
  118. pivtools_gui/vector_merging/app/__init__.py +1 -0
  119. pivtools_gui/vector_merging/app/views.py +759 -0
  120. pivtools_gui/vector_statistics/app/__init__.py +1 -0
  121. pivtools_gui/vector_statistics/app/views.py +710 -0
  122. pivtools_gui/vector_statistics/ensemble_statistics.py +49 -0
  123. pivtools_gui/vector_statistics/instantaneous_statistics.py +311 -0
  124. pivtools_gui/video_maker/__init__.py +0 -0
  125. pivtools_gui/video_maker/app/__init__.py +0 -0
  126. pivtools_gui/video_maker/app/views.py +436 -0
  127. pivtools_gui/video_maker/video_maker.py +662 -0
@@ -0,0 +1,979 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ import dask.array as da
5
+ import dask.array.linalg as da_linalg
6
+ import matplotlib.pyplot as plt
7
+ import numpy as np
8
+ from dask.diagnostics.progress import ProgressBar
9
+ from scipy.io import loadmat, savemat # add
10
+
11
+ from ...config import Config
12
+ from ...paths import get_data_paths
13
+ from ...plotting.plot_maker import make_scalar_settings, plot_scalar_field
14
+ from ...vector_loading import (
15
+ load_coords_from_directory,
16
+ load_vectors_from_directory,
17
+ )
18
+
19
+
20
+ def _compute_pod(X: da.Array, k: int, normalise: bool):
21
+ """
22
+ Exact POD via snapshots method.
23
+ X: dask array of shape (L, N) [features x time], float
24
+ Returns (evals_desc, s_desc, phi_k [L,k], V_k [N,k], mu [L], std [L or None])
25
+ """
26
+ # Center (and optionally normalise) along time axis
27
+ mu = X.mean(axis=1, keepdims=True)
28
+ Xc = X - mu
29
+ if normalise:
30
+ std = X.std(axis=1, ddof=1, keepdims=True)
31
+ eps = 1e-12
32
+ Xc = Xc / (std + eps)
33
+ else:
34
+ std = None
35
+
36
+ # Exact method of snapshots: C = Xc^T Xc (N x N)
37
+ # Rechunk for optimal performance (tune chunk size as needed)
38
+ Xc = Xc.rechunk({0: -1, 1: "auto"})
39
+ L_dim = int(Xc.shape[0])
40
+
41
+ # Optionally, use Dask's ProgressBar for feedback
42
+
43
+ with ProgressBar():
44
+ C = da.dot(Xc.T, Xc)
45
+ C_np = C.compute()
46
+ # Numerical symmetrisation
47
+ C_np = 0.5 * (C_np + C_np.T)
48
+
49
+ # Exact eigendecomposition of C
50
+ evals, V = np.linalg.eigh(C_np)
51
+ order = np.argsort(evals)[::-1]
52
+ evals = evals[order]
53
+ V = V[:, order]
54
+ svals = np.sqrt(np.clip(evals, 0.0, None))
55
+
56
+ k_eff = min(k, V.shape[1])
57
+ V_k = V[:, :k_eff]
58
+ s_k = svals[:k_eff]
59
+
60
+ # Spatial modes: phi_k = Xc @ v_k / s_k
61
+ phi_cols = []
62
+ for i in range(k_eff):
63
+ vk = V_k[:, i]
64
+ phi_i = da.dot(Xc, vk) / (s_k[i] + 1e-12) # (L,)
65
+ phi_cols.append(phi_i.compute())
66
+ if phi_cols:
67
+ Phi = np.stack(phi_cols, axis=1)
68
+ else:
69
+ Phi = np.zeros((L_dim, 0), dtype=float)
70
+
71
+ mu_np = da.compute(mu)[0].ravel()
72
+ if normalise:
73
+ std_np = da.compute(std)[0].ravel()
74
+ else:
75
+ std_np = None
76
+ return evals, svals, Phi, V_k, mu_np, std_np
77
+
78
+
79
+ def _compute_pod_randomized(
80
+ X: da.Array,
81
+ k: int,
82
+ normalise: bool,
83
+ oversampling: int = 10,
84
+ power_iter: int = 1,
85
+ random_state: Optional[int] = 0,
86
+ ):
87
+ """
88
+ Randomized POD (Halko) using Dask-parallel matmuls.
89
+ X: dask array of shape (L, N) [features x time]
90
+ Returns (evals_desc, s_desc, phi_k [L,k], V_k [N,k], mu [L], std [L or None])
91
+ """
92
+ # Center and optionally normalise along time axis
93
+ mu = X.mean(axis=1, keepdims=True)
94
+ Xc = X - mu
95
+ if normalise:
96
+ std = X.std(axis=1, ddof=1, keepdims=True)
97
+ eps = 1e-12
98
+ Xc = Xc / (std + eps)
99
+ else:
100
+ std = None
101
+
102
+ Xc = Xc.rechunk({0: -1, 1: "auto"})
103
+ L, N = int(Xc.shape[0]), int(Xc.shape[1])
104
+
105
+ k_target = min(k, N)
106
+ r = min(k_target + max(0, oversampling), N)
107
+
108
+ # Random test matrix (N x r)
109
+ rng = np.random.default_rng(seed=random_state)
110
+ Omega = rng.standard_normal(size=(N, r))
111
+
112
+ # Y = Xc @ Omega -> (L x r)
113
+ Y = da.dot(Xc, Omega)
114
+
115
+ # Power iterations
116
+ for _ in range(max(0, power_iter)):
117
+ Z = da.dot(Xc.T, Y) # (N x r)
118
+ Y = da.dot(Xc, Z) # (L x r)
119
+
120
+ # Orthonormal basis Q via QR (some stubs return 2 or 3 items)
121
+ qr_out = da_linalg.qr(Y)
122
+ if isinstance(qr_out, tuple):
123
+ Q = qr_out[0]
124
+ else:
125
+ Q = qr_out
126
+
127
+ # Small matrix B = Q^T Xc -> (r x N)
128
+ with ProgressBar():
129
+ B = da.dot(Q.T, Xc).compute() # numpy
130
+
131
+ # SVD of B
132
+ Uhat, S, Vt = np.linalg.svd(B, full_matrices=False)
133
+
134
+ k_eff = min(k_target, Uhat.shape[1])
135
+ Uhat_k = Uhat[:, :k_eff]
136
+ S_k = S[:k_eff]
137
+ V_k = Vt[:k_eff, :].T # (N x k)
138
+
139
+ # Spatial modes Phi = Q @ Uhat_k
140
+ phi_cols = []
141
+ for i in range(k_eff):
142
+ qi = da.dot(Q, Uhat_k[:, i])
143
+ with ProgressBar():
144
+ phi_cols.append(qi.compute())
145
+ Phi = np.stack(phi_cols, axis=1) if phi_cols else np.zeros((L, 0), dtype=float)
146
+
147
+ evals = (S_k**2).copy()
148
+ svals = S_k.copy()
149
+
150
+ mu_np = da.compute(mu)[0].ravel()
151
+ if normalise:
152
+ std_np = da.compute(std)[0].ravel()
153
+ else:
154
+ std_np = None
155
+ return evals, svals, Phi, V_k, mu_np, std_np
156
+
157
+
158
+ def _map_modes_to_grid(phi: np.ndarray, valid_flat: np.ndarray, hw: tuple[int, int]):
159
+ """
160
+ phi: (L, k) with L=P or 2P; valid_flat: (H*W,) boolean
161
+ Returns:
162
+ - if L==P: (modes_grid [k,H,W],)
163
+ - if L==2P: (modes_ux [k,H,W], modes_uy [k,H,W])
164
+ """
165
+ H, W = hw
166
+ P = valid_flat.sum()
167
+ L, k = phi.shape
168
+ if L == P: # single component
169
+ modes = np.zeros((k, H, W), dtype=phi.dtype)
170
+ for i in range(k):
171
+ g = np.zeros(H * W, dtype=phi.dtype)
172
+ g[valid_flat] = phi[:, i]
173
+ modes[i] = g.reshape(H, W)
174
+ return (modes,)
175
+ elif L == 2 * P:
176
+ modes_ux = np.zeros((k, H, W), dtype=phi.dtype)
177
+ modes_uy = np.zeros((k, H, W), dtype=phi.dtype)
178
+ for i in range(k):
179
+ g_u = np.zeros(H * W, dtype=phi.dtype)
180
+ g_v = np.zeros(H * W, dtype=phi.dtype)
181
+ g_u[valid_flat] = phi[:P, i]
182
+ g_v[valid_flat] = phi[P:, i]
183
+ modes_ux[i] = g_u.reshape(H, W)
184
+ modes_uy[i] = g_v.reshape(H, W)
185
+ return modes_ux, modes_uy
186
+ else:
187
+ raise ValueError("phi length inconsistent with valid mask size")
188
+
189
+
190
+ def pod_decompose(cam_num: int, config: Config, base: Path, k_modes: int = 10):
191
+ """
192
+ Run POD per selected pass (run) for a given camera.
193
+ Uses randomized algorithm when settings.randomised is True.
194
+ Saves under 'POD' (exact) or 'pod_randomised' (randomized).
195
+ """
196
+ if config.post_processing is None:
197
+ return
198
+ for entry in config.post_processing:
199
+ if entry.get("type") != "POD":
200
+ continue
201
+
202
+ settings = entry.get("settings", {}) or {}
203
+ # Accept both snake-case and camelCase keys from YAML/UI
204
+ stack_u_y: bool = bool(
205
+ settings.get(
206
+ "stack_U_y", settings.get("stack_u_y", settings.get("stackUy", False))
207
+ )
208
+ )
209
+ normalise: bool = bool(settings.get("normalise", False))
210
+ use_randomised: bool = bool(settings.get("randomised", False))
211
+ oversampling: int = int(settings.get("oversampling", 10))
212
+ power_iter: int = int(settings.get("power_iter", 1))
213
+ random_state: Optional[int] = settings.get("random_state", 0)
214
+
215
+ # Allow endpoint/source selection as either top-level entry fields or inside settings
216
+ endpoint: str = entry.get("endpoint", settings.get("endpoint", ""))
217
+ use_merged: bool = bool(
218
+ entry.get("use_merged", settings.get("use_merged", False))
219
+ )
220
+ source_type: str = entry.get(
221
+ "source_type", settings.get("source_type", "instantaneous")
222
+ )
223
+
224
+ # Only first camera performs merged aggregation
225
+ if use_merged and cam_num != config.camera_numbers[0]:
226
+ continue
227
+
228
+ # Use new get_data_paths signature
229
+ paths = get_data_paths(
230
+ base_dir=base,
231
+ num_images=config.num_images,
232
+ cam=cam_num,
233
+ type_name=source_type,
234
+ endpoint=endpoint,
235
+ use_merged=use_merged,
236
+ )
237
+ data_dir = paths["data_dir"]
238
+ stats_dir = paths["stats_dir"] / ("pod_randomised" if use_randomised else "POD")
239
+ if not data_dir.exists():
240
+ print(f"[POD] Data dir missing: {data_dir}")
241
+ continue
242
+ stats_dir.mkdir(parents=True, exist_ok=True)
243
+ # Determine which runs to process (1-based labels)
244
+ selected_runs_1based = (
245
+ list(config.instantaneous_runs) if config.instantaneous_runs else []
246
+ )
247
+ # Load vector dataset lazily restricted to selected runs (if provided)
248
+ arr = load_vectors_from_directory(
249
+ data_dir,
250
+ config,
251
+ runs=selected_runs_1based if selected_runs_1based else None,
252
+ ) # (N,R_sel,3,H,W)
253
+
254
+ # Coordinates for plotting in the same order
255
+ x_list, y_list = load_coords_from_directory(
256
+ data_dir, runs=selected_runs_1based if selected_runs_1based else None
257
+ )
258
+ if not selected_runs_1based:
259
+ R = int(arr.shape[1])
260
+ selected_runs_1based = list(range(1, R + 1))
261
+
262
+ print(
263
+ f"[POD {'RAND' if use_randomised else 'EXACT'}] source={source_type}, cam={cam_num}, endpoint='{endpoint}', runs={selected_runs_1based}, stack_U_y={stack_u_y}, normalise={normalise}"
264
+ )
265
+
266
+ N = arr.shape[0] # number of time samples loaded
267
+ H = arr.shape[3]
268
+ W = arr.shape[4]
269
+
270
+ for lbl in selected_runs_1based:
271
+ # Local index inside reduced R dimension (order matches selected_runs_1based)
272
+ local_idx = selected_runs_1based.index(lbl)
273
+
274
+ # Build mask (True means masked in plotting)
275
+ b_mask = np.asarray(arr[0, local_idx, 2].compute()).astype(bool)
276
+ valid_flat = (~b_mask).ravel()
277
+ if valid_flat.sum() == 0:
278
+ print(f"[POD] No valid points for run {lbl}; skipping")
279
+ continue
280
+
281
+ # Flattened time-stacks for ux/uy
282
+ U = da.reshape(arr[:, local_idx, 0], (N, -1)) # (N, H*W)
283
+ V = da.reshape(arr[:, local_idx, 1], (N, -1)) # (N, H*W)
284
+
285
+ if stack_u_y:
286
+ # Build X = [U_valid ; V_valid]^T -> (L, N)
287
+ Usel = U[:, valid_flat]
288
+ Vsel = V[:, valid_flat]
289
+ X = da.concatenate([Usel, Vsel], axis=1).T.astype(np.float64)
290
+ if use_randomised:
291
+ evals, svals, Phi, V_k, mu, std = _compute_pod_randomized(
292
+ X,
293
+ k=k_modes,
294
+ normalise=normalise,
295
+ oversampling=oversampling,
296
+ power_iter=power_iter,
297
+ random_state=random_state,
298
+ )
299
+ else:
300
+ evals, svals, Phi, V_k, mu, std = _compute_pod(
301
+ X, k=k_modes, normalise=normalise
302
+ )
303
+ modes_tuple = _map_modes_to_grid(Phi, valid_flat, (int(H), int(W)))
304
+ if isinstance(modes_tuple, tuple) and len(modes_tuple) == 2:
305
+ modes_ux, modes_uy = modes_tuple # type: ignore[misc]
306
+ else:
307
+ # Fallback for static analysis; runtime should always return 2 when stacked
308
+ modes_ux = modes_tuple[0]
309
+ modes_uy = np.zeros_like(modes_ux)
310
+ # Save MAT
311
+ out_dir = stats_dir / f"run_{lbl:02d}"
312
+ out_dir.mkdir(parents=True, exist_ok=True)
313
+ out_file = out_dir / "POD_joint.mat"
314
+
315
+ # Build meta without None values
316
+ meta = {
317
+ "run_label": int(lbl),
318
+ "cam": int(cam_num),
319
+ "endpoint": endpoint,
320
+ "source_type": source_type,
321
+ "stack_U_y": True,
322
+ "normalise": bool(normalise),
323
+ "algorithm": "randomized" if use_randomised else "exact",
324
+ }
325
+ if use_randomised:
326
+ meta.update(
327
+ {
328
+ "oversampling": int(oversampling),
329
+ "power_iter": int(power_iter),
330
+ }
331
+ )
332
+
333
+ # Energy breakdown (exact and randomized both have svals)
334
+ s2 = np.asarray(svals) ** 2
335
+ total = float(np.sum(s2)) if s2.size else 0.0
336
+ energy_fraction = (s2 / total) if total > 0 else np.zeros_like(s2)
337
+ energy_cumulative = (
338
+ np.cumsum(energy_fraction)
339
+ if energy_fraction.size
340
+ else energy_fraction
341
+ )
342
+
343
+ # Save summary .mat for all modes (for frontend energy plot)
344
+ summary_file = out_dir / "POD_energy_summary.mat"
345
+ savemat(
346
+ summary_file,
347
+ {
348
+ "eigenvalues": evals,
349
+ "singular_values": svals,
350
+ "energy_fraction": energy_fraction,
351
+ "energy_cumulative": energy_cumulative,
352
+ "meta": meta,
353
+ },
354
+ )
355
+
356
+ # Save a cumulative energy plot (PNG) so users can inspect energy after POD
357
+ try:
358
+ fig, ax = plt.subplots(figsize=(6.0, 3.0))
359
+ modes = np.arange(1, energy_cumulative.size + 1)
360
+ ax.plot(modes, energy_cumulative, marker="o", lw=1.5)
361
+ ax.set_xlabel("Mode")
362
+ ax.set_ylabel("Cumulative Energy")
363
+ ax.set_title(f"POD cumulative energy - run {lbl}")
364
+ ax.set_ylim(0.0, 1.0)
365
+ ax.grid(True, linestyle="--", alpha=0.4)
366
+ out_png = (
367
+ out_dir / f"POD_energy_cumulative{config.plot_save_extension}"
368
+ )
369
+ fig.savefig(str(out_png), dpi=300, bbox_inches="tight")
370
+ plt.close(fig)
371
+ except Exception as e:
372
+ print(
373
+ f"[POD] Warning: failed to save cumulative energy PNG for run {lbl}: {e}"
374
+ )
375
+
376
+ savemat(
377
+ out_file,
378
+ {
379
+ "eigenvalues": evals,
380
+ "singular_values": svals,
381
+ "energy_fraction": energy_fraction,
382
+ "energy_cumulative": energy_cumulative,
383
+ "modes_ux": modes_ux, # [k,H,W]
384
+ "modes_uy": modes_uy, # [k,H,W]
385
+ "mask": b_mask.astype(np.uint8),
386
+ "meta": meta,
387
+ },
388
+ )
389
+ # Plot and save each mode as PNG and .mat
390
+ cx = x_list[local_idx] if local_idx < len(x_list) else None
391
+ cy = y_list[local_idx] if local_idx < len(y_list) else None
392
+ for k in range(min(k_modes, modes_ux.shape[0])):
393
+ # Save per-mode .mat for interactive viewers (already present)
394
+ savemat(
395
+ out_dir / f"ux_mode_{k + 1:02d}.mat",
396
+ {
397
+ "mode": modes_ux[k],
398
+ "k": int(k + 1),
399
+ "component": "ux",
400
+ "mask": b_mask.astype(np.uint8),
401
+ "meta": meta,
402
+ },
403
+ )
404
+ # Save PNG
405
+ save_base_ux = out_dir / f"ux_mode_{k + 1:02d}"
406
+ s_ux = make_scalar_settings(
407
+ config,
408
+ variable="POD ux",
409
+ run_label=lbl,
410
+ save_basepath=save_base_ux,
411
+ variable_units="",
412
+ coords_x=cx,
413
+ coords_y=cy,
414
+ )
415
+ fig, _, _ = plot_scalar_field(modes_ux[k], b_mask, s_ux)
416
+ fig.savefig(
417
+ f"{save_base_ux}{config.plot_save_extension}",
418
+ dpi=600,
419
+ bbox_inches="tight",
420
+ )
421
+ plt.close(fig)
422
+ # Save uy as .mat and PNG
423
+ savemat(
424
+ out_dir / f"uy_mode_{k + 1:02d}.mat",
425
+ {
426
+ "mode": modes_uy[k],
427
+ "k": int(k + 1),
428
+ "component": "uy",
429
+ "mask": b_mask.astype(np.uint8),
430
+ "meta": meta,
431
+ },
432
+ )
433
+ save_base_uy = out_dir / f"uy_mode_{k + 1:02d}"
434
+ s_uy = make_scalar_settings(
435
+ config,
436
+ variable="POD uy",
437
+ run_label=lbl,
438
+ save_basepath=save_base_uy,
439
+ variable_units="",
440
+ coords_x=cx,
441
+ coords_y=cy,
442
+ )
443
+ fig, _, _ = plot_scalar_field(modes_uy[k], b_mask, s_uy)
444
+ fig.savefig(
445
+ f"{save_base_uy}{config.plot_save_extension}",
446
+ dpi=600,
447
+ bbox_inches="tight",
448
+ )
449
+ plt.close(fig)
450
+ else:
451
+ # Separate UX
452
+ Usel = U[:, valid_flat].T.astype(np.float64) # (L, N)
453
+ if use_randomised:
454
+ evals_u, svals_u, Phi_u, Vku, mu_u, std_u = _compute_pod_randomized(
455
+ Usel,
456
+ k=k_modes,
457
+ normalise=normalise,
458
+ oversampling=oversampling,
459
+ power_iter=power_iter,
460
+ random_state=random_state,
461
+ )
462
+ else:
463
+ evals_u, svals_u, Phi_u, Vku, mu_u, std_u = _compute_pod(
464
+ Usel, k=k_modes, normalise=normalise
465
+ )
466
+ mapped_u = _map_modes_to_grid(Phi_u, valid_flat, (int(H), int(W)))
467
+ modes_u = mapped_u[0]
468
+
469
+ # Separate UY
470
+ Vsel = V[:, valid_flat].T.astype(np.float64) # (L, N)
471
+ if use_randomised:
472
+ evals_v, svals_v, Phi_v, Vkv, mu_v, std_v = _compute_pod_randomized(
473
+ Vsel,
474
+ k=k_modes,
475
+ normalise=normalise,
476
+ oversampling=oversampling,
477
+ power_iter=power_iter,
478
+ random_state=random_state,
479
+ )
480
+ else:
481
+ evals_v, svals_v, Phi_v, Vkv, mu_v, std_v = _compute_pod(
482
+ Vsel, k=k_modes, normalise=normalise
483
+ )
484
+ mapped_v = _map_modes_to_grid(Phi_v, valid_flat, (int(H), int(W)))
485
+ modes_v = mapped_v[0]
486
+
487
+ out_dir = stats_dir / f"run_{lbl:02d}"
488
+ out_dir.mkdir(parents=True, exist_ok=True)
489
+ out_file = out_dir / "POD_separate.mat"
490
+
491
+ # Build meta without None values
492
+ meta = {
493
+ "run_label": int(lbl),
494
+ "cam": int(cam_num),
495
+ "endpoint": endpoint,
496
+ "source_type": source_type,
497
+ "stack_U_y": False,
498
+ "normalise": bool(normalise),
499
+ "algorithm": "randomized" if use_randomised else "exact",
500
+ }
501
+ if use_randomised:
502
+ meta.update(
503
+ {
504
+ "oversampling": int(oversampling),
505
+ "power_iter": int(power_iter),
506
+ }
507
+ )
508
+
509
+ # Energy breakdown per component
510
+ s2u = np.asarray(svals_u) ** 2
511
+ s2v = np.asarray(svals_v) ** 2
512
+ totu = float(np.sum(s2u)) if s2u.size else 0.0
513
+ totv = float(np.sum(s2v)) if s2v.size else 0.0
514
+ energy_fraction_ux = (s2u / totu) if totu > 0 else np.zeros_like(s2u)
515
+ energy_fraction_uy = (s2v / totv) if totv > 0 else np.zeros_like(s2v)
516
+ energy_cumulative_ux = (
517
+ np.cumsum(energy_fraction_ux)
518
+ if energy_fraction_ux.size
519
+ else energy_fraction_ux
520
+ )
521
+ energy_cumulative_uy = (
522
+ np.cumsum(energy_fraction_uy)
523
+ if energy_fraction_uy.size
524
+ else energy_fraction_uy
525
+ )
526
+
527
+ # Save summary .mat for all modes (for frontend energy plot)
528
+ summary_file = out_dir / "POD_energy_summary.mat"
529
+ savemat(
530
+ summary_file,
531
+ {
532
+ "eigenvalues_ux": evals_u,
533
+ "singular_values_ux": svals_u,
534
+ "energy_fraction_ux": energy_fraction_ux,
535
+ "energy_cumulative_ux": energy_cumulative_ux,
536
+ "eigenvalues_uy": evals_v,
537
+ "singular_values_uy": svals_v,
538
+ "energy_fraction_uy": energy_fraction_uy,
539
+ "energy_cumulative_uy": energy_cumulative_uy,
540
+ "meta": meta,
541
+ },
542
+ )
543
+
544
+ # Save a cumulative energy plot (PNG) showing both ux and uy cumulative energy
545
+ try:
546
+ fig, ax = plt.subplots(figsize=(6.0, 3.0))
547
+ modes_u = np.arange(1, energy_cumulative_ux.size + 1)
548
+ modes_v = np.arange(1, energy_cumulative_uy.size + 1)
549
+ if energy_cumulative_ux.size > 0:
550
+ ax.plot(
551
+ modes_u,
552
+ energy_cumulative_ux,
553
+ marker="o",
554
+ lw=1.2,
555
+ label="ux",
556
+ )
557
+ if energy_cumulative_uy.size > 0:
558
+ ax.plot(
559
+ modes_v,
560
+ energy_cumulative_uy,
561
+ marker="s",
562
+ lw=1.2,
563
+ label="uy",
564
+ )
565
+ ax.set_xlabel("Mode")
566
+ ax.set_ylabel("Cumulative Energy")
567
+ ax.set_title(f"POD cumulative energy - run {lbl}")
568
+ ax.set_ylim(0.0, 1.0)
569
+ ax.grid(True, linestyle="--", alpha=0.4)
570
+ ax.legend()
571
+ out_png = (
572
+ out_dir / f"POD_energy_cumulative{config.plot_save_extension}"
573
+ )
574
+ fig.savefig(str(out_png), dpi=300, bbox_inches="tight")
575
+ plt.close(fig)
576
+ except Exception as e:
577
+ print(
578
+ f"[POD] Warning: failed to save cumulative energy PNG for run {lbl}: {e}"
579
+ )
580
+
581
+ savemat(
582
+ out_file,
583
+ {
584
+ "eigenvalues_ux": evals_u,
585
+ "singular_values_ux": svals_u,
586
+ "energy_fraction_ux": energy_fraction_ux,
587
+ "energy_cumulative_ux": energy_cumulative_ux,
588
+ "eigenvalues_uy": evals_v,
589
+ "singular_values_uy": svals_v,
590
+ "energy_fraction_uy": energy_fraction_uy,
591
+ "energy_cumulative_uy": energy_cumulative_uy,
592
+ "modes_ux": modes_u, # [k,H,W]
593
+ "modes_uy": modes_v, # [k,H,W]
594
+ "mask": b_mask.astype(np.uint8),
595
+ "meta": meta,
596
+ },
597
+ )
598
+ # Plot and save each mode as PNG and .mat
599
+ cx = x_list[local_idx] if local_idx < len(x_list) else None
600
+ cy = y_list[local_idx] if local_idx < len(y_list) else None
601
+ for k in range(min(k_modes, modes_u.shape[0])):
602
+ # Save per-mode .mat for interactive viewers (already present)
603
+ savemat(
604
+ out_dir / f"ux_mode_{k + 1:02d}.mat",
605
+ {
606
+ "mode": modes_u[k],
607
+ "k": int(k + 1),
608
+ "component": "ux",
609
+ "mask": b_mask.astype(np.uint8),
610
+ "meta": meta,
611
+ },
612
+ )
613
+ save_base_ux = out_dir / f"ux_mode_{k + 1:02d}"
614
+ s_ux = make_scalar_settings(
615
+ config,
616
+ variable="POD ux",
617
+ run_label=lbl,
618
+ save_basepath=save_base_ux,
619
+ variable_units="",
620
+ coords_x=cx,
621
+ coords_y=cy,
622
+ )
623
+ fig, _, _ = plot_scalar_field(modes_u[k], b_mask, s_ux)
624
+ fig.savefig(
625
+ f"{save_base_ux}{config.plot_save_extension}",
626
+ dpi=600,
627
+ bbox_inches="tight",
628
+ )
629
+ plt.close(fig)
630
+ savemat(
631
+ out_dir / f"uy_mode_{k + 1:02d}.mat",
632
+ {
633
+ "mode": modes_v[k],
634
+ "k": int(k + 1),
635
+ "component": "uy",
636
+ "mask": b_mask.astype(np.uint8),
637
+ "meta": meta,
638
+ },
639
+ )
640
+ save_base_uy = out_dir / f"uy_mode_{k + 1:02d}"
641
+ s_uy = make_scalar_settings(
642
+ config,
643
+ variable="POD uy",
644
+ run_label=lbl,
645
+ save_basepath=save_base_uy,
646
+ variable_units="",
647
+ coords_x=cx,
648
+ coords_y=cy,
649
+ )
650
+ fig, _, _ = plot_scalar_field(modes_v[k], b_mask, s_uy)
651
+ fig.savefig(
652
+ f"{save_base_uy}{config.plot_save_extension}",
653
+ dpi=600,
654
+ bbox_inches="tight",
655
+ )
656
+ plt.close(fig)
657
+
658
+ print(
659
+ f"[POD {'RAND' if use_randomised else 'EXACT'}] Completed POD for cam={cam_num}, endpoint='{endpoint}', saved -> {stats_dir}"
660
+ )
661
+
662
+
663
+ def pod_rebuild(cam_num: int, config: Config, base: Path):
664
+ """
665
+ Rebuild calibrated vector fields for a given camera and selected runs using a prescribed energy fraction.
666
+ - Reads POD stats (prefers 'pod_randomised', else 'POD') and their meta for stack/normalise flags.
667
+ - Projects snapshots onto leading modes to reach 'energy' and reconstructs ux, uy.
668
+ - Saves reconstructed .mat files under data endpoint 'POD_rebuild' with same filenames, only modifying the requested run.
669
+ """
670
+ if not config.post_processing:
671
+ return
672
+
673
+ # Find rebuild spec(s)
674
+ rebuild_entries = [
675
+ e for e in config.post_processing if e.get("type") == "POD_rebuild"
676
+ ]
677
+ if not rebuild_entries:
678
+ return
679
+
680
+ for entry in rebuild_entries:
681
+ settings = entry.get("settings", {}) or {}
682
+ # energy: accept 0..1 or 0..100
683
+ energy = float(settings.get("energy", 0.8))
684
+ if energy > 1.0:
685
+ energy = energy / 100.0
686
+ energy = float(np.clip(energy, 0.0, 1.0))
687
+
688
+ endpoint = "POD_rebuild" # output endpoint as requested
689
+ use_merged: bool = bool(entry.get("use_merged", False))
690
+ source_type: str = entry.get("source_type", "instantaneous")
691
+
692
+ # Only first camera performs merged aggregation
693
+ if use_merged and cam_num != config.camera_numbers[0]:
694
+ continue
695
+
696
+ # Input data (original calibrated) and stats base
697
+ in_paths = get_data_paths(
698
+ base_dir=base,
699
+ num_images=config.num_images,
700
+ cam=cam_num,
701
+ type_name=source_type,
702
+ endpoint="",
703
+ use_merged=use_merged,
704
+ )
705
+ data_in_dir = in_paths["data_dir"]
706
+ stats_base = in_paths["stats_dir"]
707
+
708
+ # Output data dir (endpoint POD_rebuild)
709
+ out_paths = get_data_paths(
710
+ base_dir=base,
711
+ num_images=config.num_images,
712
+ cam=cam_num,
713
+ type_name=source_type,
714
+ endpoint=endpoint,
715
+ use_merged=use_merged,
716
+ )
717
+ data_out_dir = out_paths["data_dir"]
718
+ data_out_dir.mkdir(parents=True, exist_ok=True)
719
+
720
+ # Copy coordinates.mat into the endpoint if available
721
+ coords_src = data_in_dir / "coordinates.mat"
722
+ coords_dst = data_out_dir / "coordinates.mat"
723
+ if coords_src.exists() and not coords_dst.exists():
724
+ try:
725
+ import shutil
726
+
727
+ shutil.copy2(coords_src, coords_dst)
728
+ except Exception as e:
729
+ print(f"[POD REBUILD] Warning: failed to copy coordinates.mat -> {e}")
730
+
731
+ # Determine which runs to process (1-based labels)
732
+ selected_runs_1based = (
733
+ list(config.instantaneous_runs) if config.instantaneous_runs else []
734
+ )
735
+
736
+ # Load vectors lazily (for the runs of interest)
737
+ arr = load_vectors_from_directory(
738
+ data_in_dir,
739
+ config,
740
+ runs=selected_runs_1based if selected_runs_1based else None,
741
+ ) # (N,R_sel,3,H,W)
742
+ # keep existing chunking from loader
743
+
744
+ if not selected_runs_1based:
745
+ R = int(arr.shape[1])
746
+ selected_runs_1based = list(range(1, R + 1))
747
+
748
+ print(
749
+ f"[POD REBUILD] source={source_type}, cam={cam_num}, runs={selected_runs_1based}, energy={energy:.3f}"
750
+ )
751
+
752
+ N = int(arr.shape[0])
753
+ H = int(arr.shape[3])
754
+ W = int(arr.shape[4])
755
+
756
+ for lbl in selected_runs_1based:
757
+ local_idx = selected_runs_1based.index(lbl)
758
+
759
+ # Locate POD stats for this run: prefer randomized, else exact
760
+ run_dir_rand = stats_base / "pod_randomised" / f"run_{lbl:02d}"
761
+ run_dir_exact = stats_base / "POD" / f"run_{lbl:02d}"
762
+ joint_file = "POD_joint.mat"
763
+ sep_file = "POD_separate.mat"
764
+
765
+ stats_path = None
766
+ joint = False
767
+ for base_dir in (run_dir_rand, run_dir_exact):
768
+ if (base_dir / joint_file).exists():
769
+ stats_path = base_dir / joint_file
770
+ joint = True
771
+ break
772
+ if (base_dir / sep_file).exists():
773
+ stats_path = base_dir / sep_file
774
+ joint = False
775
+ break
776
+ if stats_path is None:
777
+ print(
778
+ f"[POD REBUILD] No POD stats found for run {lbl} under {stats_base}"
779
+ )
780
+ continue
781
+
782
+ pod_mat = loadmat(str(stats_path), struct_as_record=False, squeeze_me=True)
783
+
784
+ # Extract meta
785
+ def _get_meta_val(meta_obj, key, default=None):
786
+ try:
787
+ if isinstance(meta_obj, dict):
788
+ return meta_obj.get(key, default)
789
+ return getattr(meta_obj, key, default)
790
+ except Exception:
791
+ return default
792
+
793
+ meta = pod_mat.get("meta", {})
794
+ stack_u_y = bool(
795
+ _get_meta_val(meta, "stack_U_y", settings.get("stack_u_y", False))
796
+ )
797
+ normalise = bool(
798
+ _get_meta_val(meta, "normalise", settings.get("normalise", False))
799
+ )
800
+
801
+ # Validate consistency between file loaded (joint/separate) and meta.stack_U_y
802
+ if joint and not stack_u_y:
803
+ print(
804
+ f"[POD REBUILD] Warning: loaded joint POD file but meta.stack_U_y=False for run {lbl}"
805
+ )
806
+ if (not joint) and stack_u_y:
807
+ print(
808
+ f"[POD REBUILD] Warning: loaded separate POD file but meta.stack_U_y=True for run {lbl}"
809
+ )
810
+
811
+ # Modes and singular values
812
+ if joint:
813
+ svals = np.asarray(pod_mat.get("singular_values", []))
814
+ modes_ux = np.asarray(pod_mat["modes_ux"]) # [k,H,W]
815
+ modes_uy = np.asarray(pod_mat["modes_uy"]) # [k,H,W]
816
+ # choose k by energy
817
+ if svals.size == 0:
818
+ print(f"[POD REBUILD] No singular values in {stats_path.name}")
819
+ continue
820
+ en_cum = np.cumsum(svals**2) / np.sum(svals**2)
821
+ k_use = (
822
+ int(np.searchsorted(en_cum, energy) + 1)
823
+ if "k_use" in locals()
824
+ else int(min(modes_ux.shape[0], modes_uy.shape[0]))
825
+ )
826
+ k_use = min(k_use, modes_ux.shape[0], modes_uy.shape[0])
827
+ else:
828
+ svals_u = np.asarray(pod_mat.get("singular_values_ux", []))
829
+ svals_v = np.asarray(pod_mat.get("singular_values_uy", []))
830
+ modes_ux = np.asarray(pod_mat["modes_ux"]) # [k,H,W]
831
+ modes_uy = np.asarray(pod_mat["modes_uy"]) # [k,H,W]
832
+ if svals_u.size == 0 or svals_v.size == 0:
833
+ print(f"[POD REBUILD] No singular values in {stats_path.name}")
834
+ continue
835
+ en_cum_u = np.cumsum(svals_u**2) / np.sum(svals_u**2)
836
+ en_cum_v = np.cumsum(svals_v**2) / np.sum(svals_v**2)
837
+ k_u = int(np.searchsorted(en_cum_u, energy) + 1)
838
+ k_v = int(np.searchsorted(en_cum_v, energy) + 1)
839
+ k_use_u = min(k_u, modes_ux.shape[0])
840
+ k_use_v = min(k_v, modes_uy.shape[0])
841
+
842
+ # Mask and valid points
843
+ b_mask = np.asarray(arr[0, local_idx, 2].compute()).astype(bool)
844
+ valid_flat = (~b_mask).ravel()
845
+ P = int(valid_flat.sum())
846
+ if P == 0:
847
+ print(f"[POD REBUILD] No valid points for run {lbl}; skipping")
848
+ continue
849
+
850
+ # Flattened time stacks
851
+ U = arr[:, local_idx, 0].reshape((N, -1)) # (N, H*W)
852
+ V = arr[:, local_idx, 1].reshape((N, -1)) # (N, H*W)
853
+ Usel = U[:, valid_flat] # (N, P)
854
+ Vsel = V[:, valid_flat] # (N, P)
855
+
856
+ # Compute mu and std across time consistent with POD preprocessing
857
+ # If POD used mean subtraction, compute; else zeros
858
+
859
+ mu_u = np.asarray(Usel.mean(axis=0).compute())
860
+ mu_v = np.asarray(Vsel.mean(axis=0).compute())
861
+
862
+ if normalise:
863
+ # ddof=1 to match POD
864
+ std_u = np.asarray(Usel.std(axis=0, ddof=1).compute())
865
+ std_v = np.asarray(Vsel.std(axis=0, ddof=1).compute())
866
+ std_u = std_u + 1e-12
867
+ std_v = std_v + 1e-12
868
+ else:
869
+ std_u = np.ones(P, dtype=np.float64)
870
+ std_v = np.ones(P, dtype=np.float64)
871
+
872
+ # Build Phi matrices (valid points only)
873
+ if joint:
874
+ # k modes shared
875
+ k_use = int(k_use)
876
+ Phi_u = (
877
+ modes_ux[:k_use].reshape((k_use, H * W))[:, valid_flat].T
878
+ ) # (P, k)
879
+ Phi_v = (
880
+ modes_uy[:k_use].reshape((k_use, H * W))[:, valid_flat].T
881
+ ) # (P, k)
882
+ # Stack features: [u_valid; v_valid]
883
+ Phi = np.vstack([Phi_u, Phi_v]) # (2P, k)
884
+ # Build Xc normalized for all times
885
+ X_u_c = (Usel - mu_u) / std_u # (N,P)
886
+ X_v_c = (Vsel - mu_v) / std_v # (N,P)
887
+ Xc = da.concatenate([X_u_c, X_v_c], axis=1) # (N, 2P)
888
+ # Coeffs A = Xc @ Phi
889
+ A = da.dot(Xc, Phi) # (N, k)
890
+ # Recon in feature space
891
+ Xc_hat = da.dot(A, Phi.T) # (N, 2P)
892
+ # Split and de-normalise
893
+ Xc_hat_u = Xc_hat[:, :P]
894
+ Xc_hat_v = Xc_hat[:, P:]
895
+ Urec_valid = Xc_hat_u * std_u + mu_u # (N,P)
896
+ Vrec_valid = Xc_hat_v * std_v + mu_v # (N,P)
897
+ else:
898
+ # Separate UX
899
+ Phi_u = (
900
+ modes_ux[:k_use_u].reshape((k_use_u, H * W))[:, valid_flat].T
901
+ ) # (P, ku)
902
+ Xu_c = (Usel - mu_u) / std_u # (N,P)
903
+ Au = da.dot(Xu_c, Phi_u) # (N, ku)
904
+ Xu_c_hat = da.dot(Au, Phi_u.T) # (N,P)
905
+ Urec_valid = Xu_c_hat * std_u + mu_u
906
+ # Separate UY
907
+ Phi_v = (
908
+ modes_uy[:k_use_v].reshape((k_use_v, H * W))[:, valid_flat].T
909
+ ) # (P, kv)
910
+ Xv_c = (Vsel - mu_v) / std_v # (N,P)
911
+ Av = da.dot(Xv_c, Phi_v) # (N, kv)
912
+ Xv_c_hat = da.dot(Av, Phi_v.T) # (N,P)
913
+ Vrec_valid = Xv_c_hat * std_v + mu_v
914
+
915
+ # Prepare saving reconstructed frames to endpoint directory
916
+ fmt = config.vector_format # e.g., "%05d.mat"
917
+ data_out_dir.mkdir(parents=True, exist_ok=True)
918
+
919
+ # Utility to write piv_result using original as template
920
+ import scipy.io as sio
921
+
922
+ for t in range(N):
923
+ # Prepare full grids with original for masked points
924
+ u_full = np.asarray(U[t].compute()).reshape(H * W)
925
+ v_full = np.asarray(V[t].compute()).reshape(H * W)
926
+ # Replace only valid entries with reconstructed
927
+ u_flat = u_full.copy()
928
+ v_flat = v_full.copy()
929
+ u_flat[valid_flat] = np.asarray(Urec_valid[t].compute())
930
+ v_flat[valid_flat] = np.asarray(Vrec_valid[t].compute())
931
+ u_grid = u_flat.reshape(H, W)
932
+ v_grid = v_flat.reshape(H, W)
933
+
934
+ # Read original .mat to preserve structure (especially multi-run)
935
+ in_file = data_in_dir / (fmt % (t + 1))
936
+ if not in_file.exists():
937
+ continue
938
+ mat_in = sio.loadmat(
939
+ str(in_file), struct_as_record=False, squeeze_me=True
940
+ )
941
+ piv_result_in = mat_in["piv_result"]
942
+
943
+ # Build MATLAB struct array for piv_result
944
+ def _to_struct_array(piv, run_zero_based, u_new, v_new, bmask):
945
+ dtype = np.dtype([("ux", "O"), ("uy", "O"), ("b_mask", "O")])
946
+ if isinstance(piv, np.ndarray) and piv.dtype == object:
947
+ R = piv.size
948
+ out = np.empty((R,), dtype=dtype)
949
+ for rr in range(R):
950
+ pr = piv[rr]
951
+ out[rr]["ux"] = (
952
+ u_new if rr == run_zero_based else np.asarray(pr.ux)
953
+ )
954
+ out[rr]["uy"] = (
955
+ v_new if rr == run_zero_based else np.asarray(pr.uy)
956
+ )
957
+ out[rr]["b_mask"] = np.asarray(pr.b_mask)
958
+ return out
959
+ else:
960
+ out = np.empty((1,), dtype=dtype)
961
+ out[0]["ux"] = u_new
962
+ out[0]["uy"] = v_new
963
+ out[0]["b_mask"] = np.asarray(piv.b_mask)
964
+ return out
965
+
966
+ piv_struct = _to_struct_array(
967
+ piv_result_in,
968
+ selected_runs_1based.index(lbl),
969
+ u_grid,
970
+ v_grid,
971
+ b_mask,
972
+ )
973
+
974
+ out_file = data_out_dir / (fmt % (t + 1))
975
+ sio.savemat(
976
+ str(out_file), {"piv_result": piv_struct}, do_compression=True
977
+ )
978
+
979
+ print(f"[POD REBUILD] Run {lbl} -> saved to {data_out_dir}")