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.
- pivtools-0.1.3.dist-info/METADATA +222 -0
- pivtools-0.1.3.dist-info/RECORD +127 -0
- pivtools-0.1.3.dist-info/WHEEL +5 -0
- pivtools-0.1.3.dist-info/entry_points.txt +3 -0
- pivtools-0.1.3.dist-info/top_level.txt +3 -0
- pivtools_cli/__init__.py +5 -0
- pivtools_cli/_build_marker.c +25 -0
- pivtools_cli/_build_marker.cp311-win_amd64.pyd +0 -0
- pivtools_cli/cli.py +225 -0
- pivtools_cli/example.py +139 -0
- pivtools_cli/lib/PIV_2d_cross_correlate.c +334 -0
- pivtools_cli/lib/PIV_2d_cross_correlate.h +22 -0
- pivtools_cli/lib/common.h +36 -0
- pivtools_cli/lib/interp2custom.c +146 -0
- pivtools_cli/lib/interp2custom.h +48 -0
- pivtools_cli/lib/peak_locate_gsl.c +711 -0
- pivtools_cli/lib/peak_locate_gsl.h +40 -0
- pivtools_cli/lib/peak_locate_gsl_print.c +736 -0
- pivtools_cli/lib/peak_locate_lm.c +751 -0
- pivtools_cli/lib/peak_locate_lm.h +27 -0
- pivtools_cli/lib/xcorr.c +342 -0
- pivtools_cli/lib/xcorr.h +31 -0
- pivtools_cli/lib/xcorr_cache.c +78 -0
- pivtools_cli/lib/xcorr_cache.h +26 -0
- pivtools_cli/piv/interp2custom/interp2custom.py +69 -0
- pivtools_cli/piv/piv.py +240 -0
- pivtools_cli/piv/piv_backend/base.py +825 -0
- pivtools_cli/piv/piv_backend/cpu_instantaneous.py +1005 -0
- pivtools_cli/piv/piv_backend/factory.py +28 -0
- pivtools_cli/piv/piv_backend/gpu_instantaneous.py +15 -0
- pivtools_cli/piv/piv_backend/infilling.py +445 -0
- pivtools_cli/piv/piv_backend/outlier_detection.py +306 -0
- pivtools_cli/piv/piv_backend/profile_cpu_instantaneous.py +230 -0
- pivtools_cli/piv/piv_result.py +40 -0
- pivtools_cli/piv/save_results.py +342 -0
- pivtools_cli/piv_cluster/cluster.py +108 -0
- pivtools_cli/preprocessing/filters.py +399 -0
- pivtools_cli/preprocessing/preprocess.py +79 -0
- pivtools_cli/tests/helpers.py +107 -0
- pivtools_cli/tests/instantaneous_piv/test_piv_integration.py +167 -0
- pivtools_cli/tests/instantaneous_piv/test_piv_integration_multi.py +553 -0
- pivtools_cli/tests/preprocessing/test_filters.py +41 -0
- pivtools_core/__init__.py +5 -0
- pivtools_core/config.py +703 -0
- pivtools_core/config.yaml +135 -0
- pivtools_core/image_handling/__init__.py +0 -0
- pivtools_core/image_handling/load_images.py +464 -0
- pivtools_core/image_handling/readers/__init__.py +53 -0
- pivtools_core/image_handling/readers/generic_readers.py +50 -0
- pivtools_core/image_handling/readers/lavision_reader.py +190 -0
- pivtools_core/image_handling/readers/registry.py +24 -0
- pivtools_core/paths.py +49 -0
- pivtools_core/vector_loading.py +248 -0
- pivtools_gui/__init__.py +3 -0
- pivtools_gui/app.py +687 -0
- pivtools_gui/calibration/__init__.py +0 -0
- pivtools_gui/calibration/app/__init__.py +0 -0
- pivtools_gui/calibration/app/views.py +1186 -0
- pivtools_gui/calibration/calibration_planar/planar_calibration_production.py +570 -0
- pivtools_gui/calibration/vector_calibration_production.py +544 -0
- pivtools_gui/config.py +703 -0
- pivtools_gui/image_handling/__init__.py +0 -0
- pivtools_gui/image_handling/load_images.py +464 -0
- pivtools_gui/image_handling/readers/__init__.py +53 -0
- pivtools_gui/image_handling/readers/generic_readers.py +50 -0
- pivtools_gui/image_handling/readers/lavision_reader.py +190 -0
- pivtools_gui/image_handling/readers/registry.py +24 -0
- pivtools_gui/masking/__init__.py +0 -0
- pivtools_gui/masking/app/__init__.py +0 -0
- pivtools_gui/masking/app/views.py +123 -0
- pivtools_gui/paths.py +49 -0
- pivtools_gui/piv_runner.py +261 -0
- pivtools_gui/pivtools.py +58 -0
- pivtools_gui/plotting/__init__.py +0 -0
- pivtools_gui/plotting/app/__init__.py +0 -0
- pivtools_gui/plotting/app/views.py +1671 -0
- pivtools_gui/plotting/plot_maker.py +220 -0
- pivtools_gui/post_processing/POD/__init__.py +0 -0
- pivtools_gui/post_processing/POD/app/__init__.py +0 -0
- pivtools_gui/post_processing/POD/app/views.py +647 -0
- pivtools_gui/post_processing/POD/pod_decompose.py +979 -0
- pivtools_gui/post_processing/POD/views.py +1096 -0
- pivtools_gui/post_processing/__init__.py +0 -0
- pivtools_gui/static/404.html +1 -0
- pivtools_gui/static/_next/static/chunks/117-d5793c8e79de5511.js +2 -0
- pivtools_gui/static/_next/static/chunks/484-cfa8b9348ce4f00e.js +1 -0
- pivtools_gui/static/_next/static/chunks/869-320a6b9bdafbb6d3.js +1 -0
- pivtools_gui/static/_next/static/chunks/app/_not-found/page-12f067ceb7415e55.js +1 -0
- pivtools_gui/static/_next/static/chunks/app/layout-b907d5f31ac82e9d.js +1 -0
- pivtools_gui/static/_next/static/chunks/app/page-334cc4e8444cde2f.js +1 -0
- pivtools_gui/static/_next/static/chunks/fd9d1056-ad15f396ddf9b7e5.js +1 -0
- pivtools_gui/static/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
- pivtools_gui/static/_next/static/chunks/main-a1b3ced4d5f6d998.js +1 -0
- pivtools_gui/static/_next/static/chunks/main-app-8a63c6f5e7baee11.js +1 -0
- pivtools_gui/static/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
- pivtools_gui/static/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
- pivtools_gui/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- pivtools_gui/static/_next/static/chunks/webpack-4a8ca7c99e9bb3d8.js +1 -0
- pivtools_gui/static/_next/static/css/7d3f2337d7ea12a5.css +3 -0
- pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_buildManifest.js +1 -0
- pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_ssgManifest.js +1 -0
- pivtools_gui/static/file.svg +1 -0
- pivtools_gui/static/globe.svg +1 -0
- pivtools_gui/static/grid.svg +8 -0
- pivtools_gui/static/index.html +1 -0
- pivtools_gui/static/index.txt +8 -0
- pivtools_gui/static/next.svg +1 -0
- pivtools_gui/static/vercel.svg +1 -0
- pivtools_gui/static/window.svg +1 -0
- pivtools_gui/stereo_reconstruction/__init__.py +0 -0
- pivtools_gui/stereo_reconstruction/app/__init__.py +0 -0
- pivtools_gui/stereo_reconstruction/app/views.py +1985 -0
- pivtools_gui/stereo_reconstruction/stereo_calibration_production.py +606 -0
- pivtools_gui/stereo_reconstruction/stereo_reconstruction_production.py +544 -0
- pivtools_gui/utils.py +63 -0
- pivtools_gui/vector_loading.py +248 -0
- pivtools_gui/vector_merging/__init__.py +1 -0
- pivtools_gui/vector_merging/app/__init__.py +1 -0
- pivtools_gui/vector_merging/app/views.py +759 -0
- pivtools_gui/vector_statistics/app/__init__.py +1 -0
- pivtools_gui/vector_statistics/app/views.py +710 -0
- pivtools_gui/vector_statistics/ensemble_statistics.py +49 -0
- pivtools_gui/vector_statistics/instantaneous_statistics.py +311 -0
- pivtools_gui/video_maker/__init__.py +0 -0
- pivtools_gui/video_maker/app/__init__.py +0 -0
- pivtools_gui/video_maker/app/views.py +436 -0
- 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}")
|