Coverage for dragonfly_sim/core/pod.py: 88%

274 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 03:37 +0000

1""" 

2Proper orthogonal decomposition (POD) of solution snapshots, distributed over 

3MPI with PETSc. 

4 

5SnapshotMatrix keeps the snapshot matrix S = [x_1, ..., x_n] only as its 

6incrementally updated, optionally truncated SVD, in a weighted inner product 

7(x, y)_W = x^T W y. PODBasis turns that SVD into a reduced basis for the 

8current design point:: 

9 

10 global the leading rb_size left-singular vectors of S; 

11 local_weighted the leading left-singular vectors of S diag(w), with 

12 partition-of-unity weights w that decay with the distance 

13 between each snapshot's parameter vector and the current 

14 one (compact-support cubic by default). 

15 

16Both bases are W-orthonormal (V^T W V = I). core/reduced_order_model.py 

17builds W and drives the reduced solves. 

18""" 

19from copy import deepcopy 

20from time import perf_counter 

21 

22import numpy as np 

23import scipy.linalg as sp_la 

24from scipy.spatial.distance import cdist 

25from mpi4py import MPI 

26from petsc4py import PETSc 

27 

28from dragonfly_sim.utils.pod_utils import (petsc_append_dense_column, petsc_dense_from_local_block, 

29 petsc_small_mat_from_numpy, petsc_small_vec_from_numpy, 

30 petsc_small_mat_to_numpy, cubic_dist_hat, 

31 cubic_dist_hat_fixed, basis_size_floor, cubic_weights) 

32 

33 

34class SnapshotMatrix: 

35 """ 

36 Incremental weighted QR and SVD of the snapshot matrix. 

37 

38 Each snapshot x_k (a PETSc Vec in the state layout) updates a weighted QR 

39 decomposition S = Q R with Q^T W Q = I, then the SVD of the small R; Q is 

40 rotated into R's left-singular basis every update (Q <- Q U_R), so Q IS 

41 the SVD's U and only one dense n_dofs x rank matrix is stored. The 

42 rotation is also what makes truncation to max_rank the standard truncated 

43 incremental SVD. 

44 

45 Storage: Q distributed (MATDENSE, rows in the state layout); R, Sigma, 

46 VT small and replicated on PETSc.COMM_SELF. W is any object with 

47 W.mult(x, y) (a PETSc Mat or utils/pod_utils.DiagonalWeight), or None 

48 for the Euclidean inner product. 

49 

50 A snapshot is rejected (no new column) when its parameter vector 

51 duplicates an earlier one; its location and the weights reconstructing it 

52 from the existing snapshots are kept, and the local_weighted basis 

53 redistributes its weight onto those. A rank-deficient snapshot -- one in 

54 the span of Q up to a weighted residual ||x - Q Q^T W x||_W below 

55 qr_rank_tol ||x||_W, a test independent of the snapshot's scale -- adds a 

56 column to R/VT but not to Q, so row i of the parameter array matches 

57 column i of R/VT, never of Q. 

58 """ 

59 

60 def __init__(self, comm, n_dofs=None, W=None, qr_rank_tol=1e-10, reorthogonalize=True, 

61 max_rank=None): 

62 self.comm = comm 

63 self.n_dofs = n_dofs # expected global size, checked on the first snapshot 

64 self.W = W 

65 self.qr_rank_tol = qr_rank_tol # relative weighted residual needed for a new basis direction 

66 self.reorthogonalize = reorthogonalize 

67 self.max_rank = max_rank # None: Q gains a column per accepted snapshot, unbounded 

68 

69 self.Q = None # n_dofs x r, distributed, W-orthonormal columns (== U) 

70 self.R = None # r x n 

71 self.Sigma = None # r 

72 self.VT = None # r x n 

73 

74 self.rank = 0 

75 self.n_snapshots = 0 

76 

77 self.snapshot_parameter_array = np.array([]) 

78 self.snapshot_list_parameters = [] 

79 self.rejected_snapshot_parameters = [] 

80 # rejected_snapshot_reconstruction_weights[j] has the length n_snapshots 

81 # had when rejection j happened; consumers zero-pad it. Valid because 

82 # columns of R are only ever appended, never reordered or dropped 

83 # (truncation trims rows). 

84 self.rejected_snapshot_reconstruction_weights = [] 

85 

86 # ------------------------------------------------------------------ 

87 # Parameter bookkeeping 

88 # ------------------------------------------------------------------ 

89 def construct_snapshot_parameter_array(self): 

90 self.snapshot_parameter_array = np.vstack(self.snapshot_list_parameters) 

91 

92 def _is_duplicate_parameter(self, par_vec): 

93 if len(self.snapshot_list_parameters) == 0: 

94 return False 

95 self.construct_snapshot_parameter_array() 

96 return cdist(par_vec[None, :], self.snapshot_parameter_array).min() <= 1e-10 

97 

98 # ------------------------------------------------------------------ 

99 # Snapshot update 

100 # ------------------------------------------------------------------ 

101 def _apply_W(self, x): 

102 """(W x, owned): W x as a fresh Vec, or x itself when unweighted -- 

103 `owned` says whether the caller must destroy it.""" 

104 if self.W is None: 

105 return x, False 

106 Wx = x.duplicate() 

107 self.W.mult(x, Wx) 

108 return Wx, True 

109 

110 def process_and_append_to_snapshot_lists(self, x_k, par_vec): 

111 """ 

112 Incorporate snapshot `x_k` (not modified) with parameter vector 

113 `par_vec`. Returns True if it added a new basis direction, False if 

114 it was a duplicate parameter or rank-deficient within qr_rank_tol. 

115 """ 

116 if self._is_duplicate_parameter(par_vec): 

117 PETSc.Sys.Print("Snapshot parameter vector already present; keeping its location and " 

118 "reconstruction weights, adding no column.") 

119 Wx, owns_Wx = self._apply_W(x_k) 

120 r_vec_np, _, rho = self._project_onto_Q(x_k, Wx) 

121 x_norm = self._weighted_norm(x_k, Wx) 

122 if owns_Wx: 

123 Wx.destroy() 

124 d = np.linalg.lstsq(petsc_small_mat_to_numpy(self.R), r_vec_np, rcond=None)[0] 

125 if rho > self.qr_rank_tol * x_norm: 

126 PETSc.Sys.Print("WARNING: the snapshot at a duplicate parameter location is not well " 

127 "reconstructed by the existing basis (relative weighted residual " 

128 "{:.6e} > qr_rank_tol {:.6e})".format(rho / x_norm, self.qr_rank_tol)) 

129 self.rejected_snapshot_parameters += [deepcopy(par_vec)] 

130 self.rejected_snapshot_reconstruction_weights += [d] 

131 return False 

132 

133 if self.n_dofs is not None and self.Q is None: 

134 assert x_k.getSize() == self.n_dofs, ( 

135 "snapshot size {} does not match n_dofs {}".format(x_k.getSize(), self.n_dofs)) 

136 

137 t0 = perf_counter() 

138 new_direction_found = self._update_weighted_QR_and_SVD(x_k) 

139 self.snapshot_list_parameters += [deepcopy(par_vec)] 

140 PETSc.Sys.Print("Snapshot append time: {}".format(perf_counter() - t0)) 

141 

142 assert len(self.snapshot_list_parameters) == self.n_snapshots 

143 if not new_direction_found: 

144 PETSc.Sys.Print("Snapshot added no new basis direction (relative weighted residual below " 

145 "{}); rank remains {}.".format(self.qr_rank_tol, self.rank)) 

146 return new_direction_found 

147 

148 def _project_onto_Q(self, x_k, Wx): 

149 """ 

150 W-orthogonal projection of x_k onto Q: (r, e_local, rho) with r = 

151 Q^T W x_k (replicated), e_local this rank's slice of x_k - Q r, and 

152 rho = sqrt(e^T W e). `Wx` is W x_k (or x_k when unweighted). 

153 

154 With reorthogonalize, a second projection pass (CGS2) removes the 

155 round-off a single pass leaves when x_k is nearly in span(Q), as 

156 snapshots from nearby design points are; without it orthogonality 

157 degrades catastrophically over many updates. 

158 """ 

159 comm = self.comm 

160 x_local = x_k.getArray(readonly=True) 

161 Wx_local = Wx.getArray(readonly=True) 

162 

163 if self.Q is None: 

164 r_vec_np = np.zeros((0,)) 

165 e_local = np.array(x_local, copy=True) 

166 else: 

167 Q_local = self.Q.getDenseArray() 

168 r_vec_np = comm.allreduce(Q_local.T @ Wx_local, op=MPI.SUM) 

169 e_local = x_local - Q_local @ r_vec_np 

170 if self.reorthogonalize: 

171 e_tmp = x_k.duplicate() 

172 e_tmp.setArray(e_local) 

173 We_tmp, owns = self._apply_W(e_tmp) 

174 r_vec2_np = comm.allreduce(Q_local.T @ We_tmp.getArray(readonly=True), op=MPI.SUM) 

175 if owns: 

176 We_tmp.destroy() 

177 e_tmp.destroy() 

178 e_local = e_local - Q_local @ r_vec2_np 

179 r_vec_np = r_vec_np + r_vec2_np 

180 

181 e_vec = x_k.duplicate() 

182 e_vec.setArray(e_local) 

183 We_vec, owns = self._apply_W(e_vec) 

184 rho = float(np.sqrt(max(np.real(e_vec.dot(We_vec)), 0.0))) 

185 if owns: 

186 We_vec.destroy() 

187 e_vec.destroy() 

188 return r_vec_np, e_local, rho 

189 

190 def _weighted_norm(self, x, Wx): 

191 """||x||_W = sqrt(x^T W x), given W x. Collective.""" 

192 return float(np.sqrt(max(np.real(x.dot(Wx)), 0.0))) 

193 

194 def _update_weighted_QR_and_SVD(self, x_k): 

195 comm = self.comm 

196 Wx, owns_Wx = self._apply_W(x_k) 

197 r_vec_np, e_local, rho = self._project_onto_Q(x_k, Wx) 

198 x_norm = self._weighted_norm(x_k, Wx) 

199 if owns_Wx: 

200 Wx.destroy() 

201 # relative test: the snapshot's part outside span(Q) must exceed 

202 # qr_rank_tol times its own size (a zero snapshot never qualifies) 

203 new_direction_found = rho > self.qr_rank_tol * x_norm 

204 

205 # R (small, replicated) 

206 R_old_np = petsc_small_mat_to_numpy(self.R) 

207 if new_direction_found: 

208 if R_old_np.shape[0] > 0: 

209 R_new_np = np.block([[R_old_np, r_vec_np[:, None]], 

210 [np.zeros((1, R_old_np.shape[1])), rho]]) 

211 else: 

212 R_new_np = np.array([[rho]]) 

213 elif R_old_np.shape[0] > 0: 

214 R_new_np = np.column_stack([R_old_np, r_vec_np]) 

215 else: 

216 PETSc.Sys.Print("WARNING: first snapshot has weighted norm below tolerance {}; basis " 

217 "remains empty.".format(self.qr_rank_tol)) 

218 R_new_np = np.zeros((0, R_old_np.shape[1] + 1)) 

219 

220 # Q extended by the new direction 

221 Q_ext = petsc_append_dense_column(comm, self.Q, e_local / rho) if new_direction_found else self.Q 

222 

223 # SVD of the small R, computed redundantly (and identically) on every rank 

224 if R_new_np.shape[0] > 0 and R_new_np.shape[1] > 0: 

225 U_R_np, Sigma_np, VT_np = sp_la.svd(R_new_np, full_matrices=False) 

226 else: 

227 U_R_np, Sigma_np, VT_np = np.zeros((0, 0)), np.zeros((0,)), np.zeros((0, R_new_np.shape[1])) 

228 

229 # Rotate Q into R's left-singular basis, truncated to max_rank: 

230 # Q' = Q U_R[:, :k], R' = diag(Sigma[:k]) VT[:k]. Q' stays W-orthonormal, 

231 # Q' R' is the rank-k truncation of the snapshot matrix, and the SVD of 

232 # R' has U = I, so U = Q'. 

233 if Q_ext is not None: 

234 k = Sigma_np.shape[0] if self.max_rank is None else min(Sigma_np.shape[0], self.max_rank) 

235 Q_new = petsc_dense_from_local_block(comm, Q_ext.getDenseArray() @ U_R_np[:, :k], 

236 Q_ext.getSize()[0]) 

237 Sigma_np = Sigma_np[:k] 

238 VT_np = VT_np[:k, :] 

239 R_new_np = Sigma_np[:, None] * VT_np 

240 Q_ext.destroy() 

241 else: 

242 Q_new = None 

243 if self.Q is not None and self.Q is not Q_ext: 

244 self.Q.destroy() 

245 for obj in (self.R, self.Sigma, self.VT): 

246 if obj is not None: 

247 obj.destroy() 

248 

249 self.Q = Q_new 

250 self.R = petsc_small_mat_from_numpy(R_new_np) 

251 self.Sigma = petsc_small_vec_from_numpy(Sigma_np) 

252 self.VT = petsc_small_mat_from_numpy(VT_np) 

253 self.rank = 0 if self.Q is None else self.Q.getSize()[1] 

254 self.n_snapshots = self.R.getSize()[1] 

255 return new_direction_found 

256 

257 # ------------------------------------------------------------------ 

258 # Read-only surface 

259 # ------------------------------------------------------------------ 

260 @property 

261 def U(self): 

262 """The left-singular vectors: the same object as self.Q.""" 

263 return self.Q 

264 

265 @property 

266 def full_SVD_triplet(self): 

267 return (self.U, self.Sigma, self.VT) 

268 

269 def print_info(self): 

270 if self.Q is not None: 

271 PETSc.Sys.Print("Snapshot SVD: basis {} x {}, R {}, {} snapshots".format( 

272 self.Q.getSize()[0], self.Q.getSize()[1], self.R.getSize(), self.n_snapshots)) 

273 else: 

274 PETSc.Sys.Print("No snapshots processed yet.") 

275 

276 def gather_dense_to_rank0_numpy(self, mat, root=0): 

277 """A distributed dense Mat as one numpy array on `root` (diagnostics).""" 

278 if mat is None: 

279 return None 

280 gathered = self.comm.gather(mat.getDenseArray().copy(), root=root) 

281 return np.vstack(gathered) if self.comm.Get_rank() == root else None 

282 

283 def destroy(self): 

284 for obj in (self.Q, self.R, self.Sigma, self.VT): 

285 if obj is not None: 

286 obj.destroy() 

287 self.Q = self.R = self.Sigma = self.VT = None 

288 self.rank = 0 

289 

290 

291class PODBasis: 

292 """ 

293 A reduced basis from the snapshots in a SnapshotMatrix (see the module 

294 docstring for the basis modes). `basis` is the current basis, a 

295 distributed MATDENSE in the state layout, rebuilt (destroyed and 

296 reallocated) by every construct_basis call. 

297 """ 

298 SUPPORTED_BASIS_MODES = ('global', 'local_weighted') 

299 WEIGHT_FUNCTIONS = ('Cubic', 'Linear', 'Quadratic', 'InverseDistance') 

300 

301 def __init__(self, comm, n_dofs, basis_mode='global', subtract_mean=False, 

302 mean_weighting='snapshot_weights', IP_matrix=None, max_rank=None, qr_rank_tol=1e-10): 

303 if basis_mode not in self.SUPPORTED_BASIS_MODES: 

304 raise ValueError("basis_mode must be one of {}, got {!r}".format( 

305 self.SUPPORTED_BASIS_MODES, basis_mode)) 

306 if subtract_mean and basis_mode != 'local_weighted': 

307 # the snapshots exist only as their SVD, so centring composes into 

308 # the small weighted factor that only local_weighted forms 

309 raise NotImplementedError("subtract_mean=True needs basis_mode='local_weighted'") 

310 if mean_weighting not in ('uniform', 'snapshot_weights'): 

311 raise ValueError("mean_weighting must be 'uniform' or 'snapshot_weights', got {!r}".format( 

312 mean_weighting)) 

313 self.comm = comm 

314 self.n_dofs = n_dofs 

315 self.basis_mode = basis_mode 

316 self.subtract_mean = subtract_mean 

317 self.mean_weighting = mean_weighting 

318 self.IP_matrix = IP_matrix 

319 self.snapshot_matrix = SnapshotMatrix(comm, n_dofs, W=IP_matrix, qr_rank_tol=qr_rank_tol, 

320 reorthogonalize=True, max_rank=max_rank) 

321 self.basis = None 

322 self.mean = None 

323 

324 def _set_basis_from_local_block(self, basis_local_block): 

325 """ 

326 Rebuild self.basis from this rank's row block. (MatCreateSubMatrix is 

327 not used to slice columns of U: for MATDENSE it fails on a width 

328 change, and under MPI returns replicated columns or corrupts the heap, 

329 depending on how the column IS is split.) 

330 """ 

331 if self.basis is not None: 

332 self.basis.destroy() 

333 self.basis = petsc_dense_from_local_block(self.comm, np.ascontiguousarray(basis_local_block)) 

334 return self.basis 

335 

336 def _local_weighted_snapshot_weights(self, local_parametervector, weightfunction, 

337 parameter_weights, n_nonzero_weights, rb_size, 

338 cubic_cutoff=None): 

339 """ 

340 Partition-of-unity weights over the accepted snapshots (one per column 

341 of VT), from the distances between their parameter vectors and 

342 `local_parametervector`. Duplicate-parameter (rejected) snapshots take 

343 part in the distance weighting like accepted ones, and their weight is 

344 then redistributed onto the accepted snapshots that reconstruct them. 

345 

346 Returns (weights, distances of the accepted snapshots). 

347 """ 

348 sm = self.snapshot_matrix 

349 sm.construct_snapshot_parameter_array() 

350 n_snapshots = sm.n_snapshots 

351 rejected = sm.rejected_snapshot_parameters 

352 n_rej = len(rejected) 

353 params = sm.snapshot_parameter_array 

354 if n_rej > 0: 

355 params = np.vstack([params, np.vstack(rejected)]) 

356 

357 parameter_distance = params - local_parametervector[None, :] 

358 if parameter_weights is not None: 

359 parameter_distance = parameter_distance * np.asarray(parameter_weights)[None, :] 

360 parameter_distnorm = np.linalg.norm(parameter_distance, ord=2, axis=1) 

361 min_dist = parameter_distnorm.min() 

362 max_dist = parameter_distnorm.max() 

363 

364 # Every weighting divides by the spread of the distances; with none 

365 # (all snapshots equidistant, e.g. two symmetric perturbations) they 

366 # all reduce to uniform weights. 

367 if (max_dist - min_dist) <= 1e-12 * max(max_dist, 1.0): 

368 PETSc.Sys.Print("All snapshots are equidistant from the query point (distance {:.6e}); " 

369 "using uniform snapshot weights.".format(min_dist)) 

370 snapshot_weights = np.ones_like(parameter_distnorm) 

371 elif weightfunction == "Cubic": 

372 if cubic_cutoff is not None: 

373 # c sets the support radius, but never below the radius that 

374 # gives the rb_size nearest accepted snapshots a nonzero weight: 

375 # the basis then always has rb_size columns (when the snapshots 

376 # span that many directions), and c can only widen the set of 

377 # snapshots that contribute 

378 dist_hat_c = cubic_dist_hat_fixed(min_dist, max_dist, cubic_cutoff) 

379 dist_floor = basis_size_floor(parameter_distnorm[:n_snapshots], rb_size, 

380 max_dist - min_dist) 

381 dist_hat = max(dist_hat_c, dist_floor) 

382 PETSc.Sys.Print("cubic support radius: c = {} gives {:.6e}, rb_size = {} needs {:.6e}; " 

383 "using {:.6e}".format(cubic_cutoff, dist_hat_c, rb_size, dist_floor, dist_hat)) 

384 else: 

385 k_target = rb_size if n_nonzero_weights is None else n_nonzero_weights 

386 dist_hat = cubic_dist_hat(parameter_distnorm, min_dist, max_dist, k_target) 

387 snapshot_weights = cubic_weights(parameter_distnorm, min_dist, dist_hat) 

388 elif weightfunction == "Linear": 

389 avg_dist = (3 * min_dist + max_dist) / 4 

390 snapshot_weights = avg_dist / (avg_dist - min_dist) - parameter_distnorm / (avg_dist - min_dist) 

391 snapshot_weights[snapshot_weights < 0.] = 0. 

392 elif weightfunction == "Quadratic": 

393 denominator = max_dist**2 - min_dist**2 

394 snapshot_weights = max_dist**2 / denominator - parameter_distnorm**2 / denominator 

395 elif weightfunction == "InverseDistance": 

396 inv = 1.0 / parameter_distnorm**2 

397 snapshot_weights = inv / inv.max() 

398 else: 

399 raise ValueError("weightfunction must be one of {}, got {!r}".format( 

400 self.WEIGHT_FUNCTIONS, weightfunction)) 

401 

402 w_acc = snapshot_weights[:n_snapshots] 

403 if n_rej > 0: 

404 D = np.zeros((n_snapshots, n_rej)) 

405 for j, d in enumerate(sm.rejected_snapshot_reconstruction_weights): 

406 D[:d.shape[0], j] = d 

407 w_acc = w_acc + D @ snapshot_weights[n_snapshots:] 

408 # clip after redistribution: negative reconstruction weights can 

409 # push an accepted snapshot's total below zero 

410 w_acc[w_acc < 0.] = 0. 

411 

412 return w_acc / np.sum(w_acc), parameter_distnorm[:n_snapshots] 

413 

414 def construct_basis(self, rb_size=10, local_parametervector=None, parameter_weights=None, 

415 weightfunction='Cubic', n_nonzero_weights=None, cubic_cutoff=None, 

416 return_distance_norms=False, layout_vec=None): 

417 """ 

418 (Re)build self.basis from the current snapshot SVD; with no snapshots 

419 self.basis becomes None. 

420 

421 local_weighted only:: 

422 

423 local_parametervector the current design point 

424 parameter_weights per-parameter factors on the distance (None: 1) 

425 weightfunction 'Cubic' (compact support), 'Linear', 

426 'Quadratic' or 'InverseDistance' 

427 n_nonzero_weights Cubic: number of nearest snapshots with a 

428 nonzero weight (default rb_size) 

429 cubic_cutoff Cubic: instead of n_nonzero_weights, a 

430 c in [0, 1) with support radius 

431 max(c min_dist + (1 - c) max_dist, r_rb), 

432 r_rb the smallest radius that leaves the 

433 rb_size nearest accepted snapshots a nonzero 

434 weight (utils/pod_utils.basis_size_floor) 

435 

436 `layout_vec` (the state vector) guards the one invariant the reduced 

437 solve depends on: the basis rows are distributed exactly like it. 

438 Returns the accepted snapshots' distances with return_distance_norms 

439 (local_weighted), else None. 

440 """ 

441 U, Sig_vec, VT = self.snapshot_matrix.full_SVD_triplet 

442 n_snapshots = self.snapshot_matrix.n_snapshots 

443 basis_rank = self.snapshot_matrix.rank 

444 

445 if layout_vec is not None and U is not None and U.getLocalSize()[0] != layout_vec.getLocalSize(): 

446 raise ValueError("POD basis row layout ({} local rows) does not match the state vector " 

447 "layout ({} local rows)".format(U.getLocalSize()[0], layout_vec.getLocalSize())) 

448 

449 if n_snapshots == 0 or U is None: 

450 if self.basis is not None: 

451 self.basis.destroy() 

452 self.basis = None 

453 self.mean = None 

454 return None 

455 

456 parameter_distnorm = None 

457 if self.basis_mode == 'global' or n_snapshots < 2: 

458 # bounded by the rank, not n_snapshots: a rank-deficient snapshot 

459 # adds a column to R/VT but none to U 

460 self._set_basis_from_local_block(U.getDenseArray()[:, :min(basis_rank, rb_size)]) 

461 else: 

462 if local_parametervector is None: 

463 raise ValueError("the local_weighted basis needs local_parametervector") 

464 weights, parameter_distnorm = self._local_weighted_snapshot_weights( 

465 local_parametervector, weightfunction, parameter_weights, n_nonzero_weights, rb_size, 

466 cubic_cutoff=cubic_cutoff) 

467 PETSc.Sys.Print("snapshot weights: {}".format(weights)) 

468 

469 # diagonal weight matrix restricted to the columns above 1e-10 

470 valid_idxs = np.where(weights >= 1e-10)[0] 

471 num_rows, num_cols = len(weights), len(valid_idxs) 

472 weight_mat = np.zeros((num_rows, num_cols)) 

473 weight_mat[valid_idxs, np.arange(num_cols)] = weights[valid_idxs] 

474 

475 Sig_np = np.asarray(Sig_vec.getArray()).ravel() 

476 VT_np = VT.getDenseArray() 

477 if self.subtract_mean: 

478 # centring is the rank-1 right factor H = I - c 1^T: S_c = S H, 

479 # so H W = W - c (1^T W), and the mean S c = U (Sig * (VT c)) 

480 c_vec = weights.copy() if self.mean_weighting == 'snapshot_weights' \ 

481 else np.full(num_rows, 1.0 / max(num_rows, 1)) 

482 if self.mean is not None: 

483 self.mean.destroy() 

484 self.mean = U.createVecLeft() 

485 self.mean.getArray()[:] = U.getDenseArray() @ (Sig_np * (VT_np @ c_vec)) 

486 self.mean.assemble() 

487 weight_mat = weight_mat - np.outer(c_vec, weight_mat.sum(axis=0)) 

488 

489 # SVD of diag(Sig) VT W: small (rank x num_cols), replicated, so dense scipy 

490 t0 = perf_counter() 

491 U_tilde_np, _, _ = sp_la.svd(Sig_np[:, None] * (VT_np @ weight_mat), full_matrices=False) 

492 t1 = perf_counter() 

493 

494 # the basis is U U_tilde truncated to rb_size; truncate U_tilde first 

495 # so the intermediate never exceeds rb_size columns 

496 k = min(rb_size, U_tilde_np.shape[1]) 

497 self._set_basis_from_local_block(U.getDenseArray() @ U_tilde_np[:, :k]) 

498 PETSc.Sys.Print("local_weighted basis: {} of {} snapshots carry nonzero weight -> {} basis " 

499 "vectors (rb_size = {}); small SVD time {}".format( 

500 num_cols, n_snapshots, k, rb_size, t1 - t0)) 

501 

502 PETSc.Sys.Print("Computed POD basis: {} rows (global) x {} columns".format(*self.basis.getSize())) 

503 return parameter_distnorm if return_distance_norms else None 

504 

505 def destroy(self): 

506 if self.basis is not None: 

507 self.basis.destroy() 

508 self.basis = None 

509 if self.mean is not None: 

510 self.mean.destroy() 

511 self.mean = None 

512 self.snapshot_matrix.destroy()