Coverage for dragonfly_sim/core/reduced_order_model.py: 92%

371 statements  

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

1""" 

2POD reduced-order model (ROM) of a steady flow solve: least-squares 

3Petrov-Galerkin (LSPG) on a POD basis of converged solutions. 

4 

5The ROM works with any model built on CompressibleEulerModel -- the Euler 

6model itself and CompressibleRANSModel, which forwards to it -- through this 

7interface, and nothing model-specific:: 

8 

9 u_vec the state (its layout is the basis's row layout) 

10 set_up_solver(), problem the Newton problem: problem.F(x, b) and 

11 problem.J(x, problem._A) assemble the residual 

12 and its exact Jacobian at the current u_vec 

13 (for RANS the condensed-gradient problem, which 

14 updates the reconstructed gradient on the way) 

15 steady_residual() context in which problem assembles the steady 

16 residual (pseudo/physical time terms off) 

17 positivity_step_length step length keeping density and pressure positive 

18 state_scales() per-component magnitudes for the inner product 

19 _assemble_physical_residual(), relative_residual_norm() 

20 

21Inner product. Snapshots and basis use (u, v)_W = u^T W v with, by default, 

22the state-scaled Euclidean weight W = D^-2, D the per-component state scales 

23s_k: every component on the same footing (in absolute terms the SA variable 

24rho*nu_tilde is ~1e-4 against rho*E ~ 2, and a truncated SVD would ignore 

25it). inner_product='l2' adds the (diagonal) DG0 mass matrix, W = M D^-2, 

26i.e. int sum_k u_k v_k / s_k^2 dx; on the NACA0012 alpha sweeps it was 10-100x 

27less accurate than the Euclidean weight (M emphasizes the large far-field 

28cells). W is built once on the undeformed mesh: the incremental SVD keeps 

29Q^T W Q = I only for a fixed W. The LSPG residual is measured in the matching 

30norm ||S r||, S = D^-1 (D^-1 M^-1/2 with 'l2', on the current mesh). 

31state_scaling=None with 'euclidean' is the plain Euclidean POD/LSPG. 

32 

33Solve. With V the basis (V^T W V = I), u = V a (+ mean), LSPG is the 

34Gauss-Newton iteration on min ||S R(V a)||: 

35 

36 (S A V)^T (S A V) da = -(S A V)^T S r, a <- a + theta da, 

37 

38theta from positivity_step_length, optionally backtracked (Armijo) on 

39||S r||^2 / 2. The reduced system is small and solved redundantly on every 

40rank. Convergence follows project-sandbox benchmark_pod.py (its PODSolver and 

41outer loop): passes of at most newton_max_it iterations, each stopping on 

42||g|| = ||(S A V)^T S r|| < max(atol, rtol ||g_0||), on a negligible step, or on 

43a stall of ||g||; up to max_outer_iterations passes, converged when a pass 

44ends with ||g|| < g_conv_limit. 

45 

46Derivatives. The ROM solves the LSPG stationarity condition, the projected 

47residual equations 

48 

49 Phi^T R(V a; x) = 0, Phi = S^T S A V, 

50 

51so when its solution is accepted the adjoint is that of these k equations 

52(ReducedOrderModel.adjoint, an LSPGAdjoint). With V, S and Phi frozen at the 

53solution (the basis is not differentiated, and the Gauss-Newton terms, which 

54are proportional to S R, are dropped), for an output f with g = (df/du)^T: 

55 

56 (S A V)^T (S A V) lam = V^T g, psi = Phi lam = S^T S A V lam, 

57 

58and df/dx = df/dx|_u - psi^T dR/dx with the full-order dR/dx: psi takes the 

59place of the full-order adjoint A^-T g. It is the Petrov-Galerkin solution of 

60A^T psi = g in the span of Phi, tested with V: V^T (A^T psi - g) = 0. psi is 

61computed as S Q y with S A V = Q R (a distributed thin QR) and R^T y = V^T g, 

62without forming the normal matrix, whose condition number is squared. 

63""" 

64from dataclasses import dataclass 

65from time import perf_counter 

66 

67import numpy as np 

68import scipy.linalg as sp_la 

69from mpi4py import MPI 

70from petsc4py import PETSc 

71 

72from dragonfly_sim.core.pod import PODBasis 

73from dragonfly_sim.utils.pod_utils import DiagonalWeight, tsqr 

74from dragonfly_sim.utils.derived_fields import diagonal_mass_inverse 

75 

76 

77def scaled_jacobian_basis(model, V, S, AV=None): 

78 """ 

79 S A V at the model's current u_vec, A the exact Jacobian assembled by its 

80 Newton problem (problem.J). Returns (this rank's rows of S A V, the PETSc 

81 product A V); `AV`, if given, is a product of the same shape to reuse. 

82 Without S the rows are a view of AV's array. 

83 """ 

84 problem = model.problem 

85 problem.J(model.u_vec.x.petsc_vec, problem._A) 

86 A = problem._A 

87 if AV is None: 

88 AV = A.matMult(V) 

89 else: 

90 A.matMult(V, result=AV) 

91 SAV = AV.getDenseArray() 

92 if S is not None: 

93 SAV = S.getArray(readonly=True)[:, None] * SAV 

94 return SAV, AV 

95 

96 

97class LSPGSolver: 

98 """ 

99 Reduced Newton (Gauss-Newton) loop on a POD basis, driving a model's 

100 Newton problem (see the module docstring). 

101 

102 Parameters 

103 ---------- 

104 V : PETSc.Mat 

105 The basis, a distributed MATDENSE in the state layout. 

106 W : PETSc.Vec or None 

107 The diagonal inner-product weight, for the restriction 

108 a = V^T W (u - mean); None: Euclidean (V^T V = I). 

109 S : PETSc.Vec or None 

110 The diagonal residual weight; None: the plain residual norm. 

111 mean : PETSc.Vec or None 

112 Optional affine offset (u = V a + mean). 

113 """ 

114 

115 def __init__(self, V, comm, W=None, S=None, mean=None, rtol=1e-10, atol=1e-12, max_it=30, 

116 stall_window=3, stall_rtol=1e-10, line_search=True, armijo_c=1e-4, max_backtracks=10): 

117 self.V = V 

118 self.comm = comm 

119 self.W = W 

120 self.S = S 

121 self.mean = mean 

122 self.rtol = rtol 

123 self.atol = atol 

124 self.max_it = max_it 

125 self.stall_window = stall_window 

126 self.stall_rtol = stall_rtol 

127 self.line_search = line_search 

128 self.armijo_c = armijo_c 

129 self.max_backtracks = max_backtracks 

130 self.converged = False 

131 self.stalled = False 

132 self.n_iterations = 0 

133 self.last_g_norm = None 

134 self._AV = None 

135 self._r = None 

136 

137 @property 

138 def rb_size(self): 

139 return self.V.getSize()[1] 

140 

141 def destroy(self): 

142 for name in ("_AV", "_r"): 

143 obj = getattr(self, name) 

144 if obj is not None: 

145 obj.destroy() 

146 setattr(self, name, None) 

147 

148 # -- maps between the full and reduced spaces -------------------------- 

149 def restrict(self, u_vec): 

150 """a = V^T W (u - mean), replicated on every rank.""" 

151 x = u_vec.getArray(readonly=True).copy() 

152 if self.mean is not None: 

153 x -= self.mean.getArray(readonly=True) 

154 if self.W is not None: 

155 x *= self.W.getArray(readonly=True) 

156 return self.comm.allreduce(self.V.getDenseArray().T @ x, op=MPI.SUM) 

157 

158 def expand(self, a, u): 

159 """u <- V a (+ mean), ghosts updated.""" 

160 x = u.x.petsc_vec 

161 x.array[:] = self.V.getDenseArray() @ a 

162 if self.mean is not None: 

163 x.axpy(1.0, self.mean) 

164 u.x.scatter_forward() 

165 

166 # -- the reduced Newton loop --------------------------------------------- 

167 def _weighted_residual(self, model): 

168 """Assemble r at the current u_vec; returns (S r local entries, ||S r||).""" 

169 problem = model.problem 

170 if self._r is None: 

171 self._r = problem._b.duplicate() 

172 problem.F(model.u_vec.x.petsc_vec, self._r) 

173 r = self._r.getArray(readonly=True) 

174 if self.S is not None: 

175 r = self.S.getArray(readonly=True) * r 

176 return r, float(np.sqrt(self.comm.allreduce(float(r @ r), op=MPI.SUM))) 

177 

178 def _reduced_system(self, model): 

179 """(H, g, ||S r||) with H = (S A V)^T (S A V), g = (S A V)^T S r at the 

180 current u_vec.""" 

181 r, r_norm = self._weighted_residual(model) 

182 if self._AV is not None and self._AV.getSize()[1] != self.V.getSize()[1]: 

183 self._AV.destroy() 

184 self._AV = None 

185 SAV, self._AV = scaled_jacobian_basis(model, self.V, self.S, self._AV) 

186 H = self.comm.allreduce(SAV.T @ SAV, op=MPI.SUM) 

187 g = self.comm.allreduce(SAV.T @ r, op=MPI.SUM) 

188 return H, g, r_norm 

189 

190 def solve(self, model): 

191 """ 

192 One pass of the LSPG Gauss-Newton loop: advance model.u_vec, starting 

193 from its restriction onto the basis, towards the LSPG solution in the 

194 span of V. Call inside model.steady_residual() for a steady solve. 

195 Returns (n_iterations, converged). ReducedOrderModel.solve runs up to 

196 max_outer_iterations passes. 

197 

198 Stopping rules (those of the project-sandbox PODSolver, which 

199 benchmark_pod.py uses), checked at every iteration from the reduced 

200 right-hand side g = (S A V)^T S r of the current state:: 

201 

202 converged ||g|| < max(atol, rtol ||g_0||), g_0 from this pass's first 

203 iteration; or, after a step, ||da|| < max(atol, 

204 rtol max(||a||, 1)) 

205 stalled ||g|| > (1 - stall_rtol) times its value stall_window 

206 iterations earlier 

207 stop non-finite ||g||, a singular reduced Jacobian, or max_it 

208 

209 Step: the Gauss-Newton step da limited to the positivity-preserving 

210 length, then (line_search) halved until the merit ||S r||^2 / 2 

211 decreases sufficiently (Armijo), at most max_backtracks times; if no 

212 halving does, the positivity-limited step is taken as is. A full 

213 Gauss-Newton step can overshoot when the minimum residual in the 

214 basis is large. 

215 """ 

216 u = model.u_vec 

217 a = self.restrict(u.x.petsc_vec) 

218 dx = u.x.petsc_vec.duplicate() 

219 self.converged = self.stalled = False 

220 g0 = None 

221 history = [] 

222 n = 0 

223 PETSc.Sys.Print("LSPG ROM solve, basis width {}".format(self.rb_size)) 

224 for n in range(self.max_it): 

225 self.expand(a, u) 

226 H, g, r_norm = self._reduced_system(model) 

227 g_norm = float(np.linalg.norm(g)) 

228 self.last_g_norm = g_norm 

229 g0 = g_norm if g0 is None else g0 

230 if not np.isfinite(g_norm): 

231 PETSc.Sys.Print("ROM: non-finite reduced residual at iteration {}; stopping".format(n)) 

232 break 

233 if g_norm < max(self.atol, self.rtol * g0): 

234 self.converged = True 

235 PETSc.Sys.Print("ROM converged at iteration {}: |g| {:.6e} (from {:.6e})".format(n, g_norm, g0)) 

236 break 

237 history.append(g_norm) 

238 if len(history) > self.stall_window and \ 

239 g_norm > (1.0 - self.stall_rtol) * history[-(self.stall_window + 1)]: 

240 self.stalled = True 

241 PETSc.Sys.Print("ROM stalled at iteration {}: |g| {:.6e}, vs {:.6e} {} iterations " 

242 "earlier".format(n, g_norm, history[-(self.stall_window + 1)], 

243 self.stall_window)) 

244 break 

245 try: 

246 da = np.linalg.solve(H, -g) 

247 except np.linalg.LinAlgError: 

248 PETSc.Sys.Print("ROM: singular reduced Jacobian at iteration {}".format(n)) 

249 break 

250 # the full-space update is +V da; positivity_step_length limits x - theta dx 

251 dx.array[:] = -(self.V.getDenseArray() @ da) 

252 theta, _ = model.positivity_step_length(u.x.petsc_vec, dx) 

253 if self.line_search: 

254 theta = self._backtrack(model, a, da, theta, 0.5 * r_norm**2, float(g @ da)) 

255 a = a + theta * da 

256 da_norm = float(np.linalg.norm(da)) 

257 PETSc.Sys.Print("ROM iter {}: |g| = {:.6e}, ||S r|| = {:.6e}, |da| = {:.6e}, theta = {:.4f}".format( 

258 n, g_norm, r_norm, da_norm, theta)) 

259 if da_norm < max(self.atol, self.rtol * max(float(np.linalg.norm(a)), 1.0)): 

260 self.converged = True 

261 PETSc.Sys.Print("ROM converged at iteration {}: |da| {:.6e}".format(n, da_norm)) 

262 break 

263 self.expand(a, u) 

264 dx.destroy() 

265 if not self.converged: 

266 PETSc.Sys.Print("[warning] ROM pass stopped after {} iterations without converging{}".format( 

267 n + 1, " (stalled)" if self.stalled else "")) 

268 self.n_iterations = n + 1 

269 return self.n_iterations, self.converged 

270 

271 def _backtrack(self, model, a, da, theta, phi, slope): 

272 """Largest theta * 2^-k (k <= max_backtracks) with phi(a + theta da) 

273 <= phi + armijo_c theta slope; the given theta if there is none.""" 

274 t = theta 

275 for _ in range(self.max_backtracks + 1): 

276 self.expand(a + t * da, model.u_vec) 

277 _, r_trial = self._weighted_residual(model) 

278 if np.isfinite(r_trial) and 0.5 * r_trial**2 <= phi + self.armijo_c * t * slope: 

279 return t 

280 t *= 0.5 

281 PETSc.Sys.Print("ROM: no Armijo step along da; taking theta = {:.4f}".format(theta)) 

282 return theta 

283 

284 

285class LSPGAdjoint: 

286 """ 

287 The adjoint of an accepted LSPG solution (Derivatives in the module 

288 docstring): psi = S Q y, with S A V = Q R and R^T y = V^T g, for the basis 

289 V and residual weight S of that solve. The factorization is formed by the 

290 first solve() and reused for every later right-hand side (CSDL passes one 

291 per output row). It belongs to one design and state; matches() checks 

292 both. 

293 

294 Parameters 

295 ---------- 

296 V : PETSc.Mat 

297 The basis of the solve (owned by the POD, not by this object). 

298 S : PETSc.Vec or None 

299 The diagonal residual weight of the solve; destroyed with this object. 

300 parameter_vector, solution 

301 The design and this rank's owned entries of the accepted solution. 

302 """ 

303 

304 def __init__(self, V, S, comm, parameter_vector, solution): 

305 self.V = V 

306 self.S = S 

307 self.comm = comm 

308 self.parameter_vector = np.array(parameter_vector, dtype=float).ravel() 

309 self.solution = np.array(solution, dtype=float) 

310 self._Q = None 

311 self._R = None 

312 

313 @property 

314 def rb_size(self): 

315 return self.V.getSize()[1] 

316 

317 def matches(self, parameter_vector, u_local): 

318 """Whether this is the adjoint at design `parameter_vector` and state 

319 `u_local` (owned entries): both equal to the accepted solve's on every 

320 rank. Collective.""" 

321 pv = np.asarray(parameter_vector, dtype=float).ravel() 

322 local = bool(np.array_equal(pv, self.parameter_vector) 

323 and np.array_equal(np.asarray(u_local, dtype=float), self.solution)) 

324 return self.comm.allreduce(local, op=MPI.LAND) 

325 

326 def _factor(self, model): 

327 """Q, R of S A V, A the steady Jacobian at the model's current u_vec.""" 

328 model.set_up_solver() 

329 with model.steady_residual(): 

330 SAV, AV = scaled_jacobian_basis(model, self.V, self.S) 

331 self._Q, self._R = tsqr(self.comm, SAV) 

332 AV.destroy() 

333 diag = np.abs(np.diag(self._R)) 

334 if not np.all(np.isfinite(self._R)) or diag.min() <= np.finfo(float).eps * diag.max(): 

335 raise ValueError("reduced adjoint: S A V is singular to working precision " 

336 "(|R_ii| from {:.3e} to {:.3e})".format(diag.min(), diag.max())) 

337 

338 def solve(self, model, rhs_local): 

339 """ 

340 psi (this rank's owned entries) for the right-hand side g, rhs_local 

341 being its owned entries. The model's u_vec, mesh and angle of attack 

342 must be those of the accepted solution (the first call assembles the 

343 Jacobian there). Collective. 

344 """ 

345 first = self._Q is None 

346 if first: 

347 self._factor(model) 

348 rhs = np.asarray(rhs_local, dtype=float) 

349 b = self.comm.allreduce(self.V.getDenseArray().T @ rhs, op=MPI.SUM) 

350 y = sp_la.solve_triangular(self._R, b, trans='T', lower=False) 

351 psi = self._Q @ y 

352 if self.S is not None: 

353 psi = self.S.getArray(readonly=True) * psi 

354 psi_norm = np.sqrt(self.comm.allreduce(float(psi @ psi), op=MPI.SUM)) 

355 if not np.isfinite(psi_norm): 

356 raise ValueError("reduced adjoint: non-finite solution") 

357 if first: 

358 diag = np.abs(np.diag(self._R)) 

359 PETSc.Sys.Print("reduced (LSPG) adjoint: basis width {}, |R_ii| from {:.3e} to {:.3e}, " 

360 "Petrov-Galerkin residual |V^T (A^T psi - g)| / |V^T g| = {:.3e}".format( 

361 self.rb_size, diag.min(), diag.max(), 

362 self._galerkin_residual(model, psi, rhs, b))) 

363 else: 

364 PETSc.Sys.Print("reduced (LSPG) adjoint: reused the factorization, |psi| = {:.6e}".format(psi_norm)) 

365 return psi 

366 

367 def _galerkin_residual(self, model, psi, rhs, b): 

368 """|V^T (A^T psi - g)| / |V^T g|, with the Jacobian _factor assembled 

369 (zero up to round-off).""" 

370 A = model.problem._A 

371 x, Atx = A.createVecLeft(), A.createVecRight() 

372 x.array[:] = psi 

373 A.multTranspose(x, Atx) 

374 res = self.comm.allreduce(self.V.getDenseArray().T @ (Atx.array - rhs), op=MPI.SUM) 

375 x.destroy() 

376 Atx.destroy() 

377 return float(np.linalg.norm(res) / max(np.linalg.norm(b), 1e-300)) 

378 

379 def destroy(self): 

380 if self.S is not None: 

381 self.S.destroy() 

382 self.S = None 

383 self._Q = self._R = None 

384 

385 

386@dataclass 

387class ROMResult: 

388 solution: np.ndarray # this rank's owned entries of the ROM state 

389 eta: float # ||R|| / ||u|| of the full steady residual 

390 residual_norm: float 

391 converged: bool # the reduced Newton loop converged 

392 accepted: bool # eta < eta_threshold 

393 iterations: int 

394 basis_width: int 

395 walltime: float 

396 

397 

398class ReducedOrderModel: 

399 """ 

400 POD snapshots, basis and LSPG solve for DG_windtunnel_model 

401 (reduced_order_model=...). One instance per windtunnel model. 

402 

403 Basis:: 

404 

405 rb_size basis width 

406 basis_mode 'global' or 'local_weighted' (core/pod.py) 

407 max_rank cap on the stored snapshot SVD rank (default 3 rb_size) 

408 qr_rank_tol relative weighted residual (outside the span of the stored 

409 snapshots) below which a snapshot adds no direction 

410 weightfunction, n_nonzero_weights, cubic_cutoff 

411 local_weighted weighting (PODBasis.construct_basis); 

412 cubic_cutoff = c sets the cubic support radius to 

413 c min_dist + (1 - c) max_dist, widened if needed so 

414 that the rb_size nearest snapshots keep a weight 

415 parameter_scales fixed per-parameter factors on the snapshot distance 

416 distance_weighting output-sensitivity weights on the snapshot distance, from the 

417 total derivatives of the objective and constraints 

418 (utils/dv_sensitivity.py): None (off), 'total_derivative' 

419 (default; outputs divided by their own magnitude) or 

420 'total_derivative_raw'. They are multiplied with 

421 parameter_scales. Without a derivative probe feeding 

422 set_distance_sensitivities there is no information, and the 

423 distance stays unweighted 

424 

425 Inner product (default: Euclidean with free-stream state scaling):: 

426 

427 inner_product 'euclidean' or 'l2' (DG0 mass matrix) 

428 state_scaling 'freestream' (model.state_scales()), None, or one 

429 scale per state component 

430 

431 Solve and acceptance (the defaults are project-sandbox benchmark_pod.py's):: 

432 

433 newton_max_it, newton_rtol, newton_atol, stall_window, stall_rtol 

434 per-pass LSPGSolver stopping rules 

435 max_outer_iterations 

436 LSPG passes, each restarted from the current state 

437 g_conv_limit the ROM solve has converged when the last ||g|| of a 

438 pass is below this 

439 line_search Armijo damping of the Gauss-Newton step 

440 eta_threshold the ROM solution is accepted when ||R||/||u|| is below it 

441 min_snapshots snapshots needed before the ROM is tried 

442 run_fom_every_evaluation 

443 also run the FOM when the ROM is accepted (benchmarking; 

444 the accepted output stays the ROM's) 

445 

446 After an accepted solve, `adjoint` is its LSPGAdjoint (None otherwise), 

447 valid until the next solve. 

448 """ 

449 

450 def __init__(self, rb_size=20, basis_mode='global', max_rank=None, qr_rank_tol=1e-10, 

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

452 parameter_scales=None, distance_weighting='total_derivative', 

453 inner_product='euclidean', state_scaling='freestream', 

454 newton_max_it=15, newton_rtol=1e-10, newton_atol=1e-12, stall_window=3, 

455 stall_rtol=1e-10, max_outer_iterations=3, g_conv_limit=1e-12, line_search=True, 

456 eta_threshold=1e-10, min_snapshots=2, run_fom_every_evaluation=False): 

457 if inner_product not in ('l2', 'euclidean'): 

458 raise ValueError("inner_product must be 'l2' or 'euclidean', got {!r}".format(inner_product)) 

459 if not (state_scaling is None or isinstance(state_scaling, str) and state_scaling == 'freestream' 

460 or np.ndim(state_scaling) == 1): 

461 raise ValueError("state_scaling must be 'freestream', None or one scale per state component") 

462 self.rb_size = rb_size 

463 self.basis_mode = basis_mode 

464 self.max_rank = 3 * rb_size if max_rank is None else max_rank 

465 self.qr_rank_tol = qr_rank_tol 

466 self.weightfunction = weightfunction 

467 self.n_nonzero_weights = n_nonzero_weights 

468 self.cubic_cutoff = cubic_cutoff 

469 self.parameter_scales = parameter_scales 

470 if distance_weighting not in (None, 'total_derivative', 'total_derivative_raw'): 

471 raise ValueError("distance_weighting must be None, 'total_derivative' or " 

472 "'total_derivative_raw', got {!r}".format(distance_weighting)) 

473 self.distance_weighting = distance_weighting 

474 # Latest mean-normalized weights, written by set_distance_sensitivities; 

475 # None until a gradient evaluation has been probed. They lag the basis 

476 # they weight by one design point. 

477 self.distance_sensitivities = None 

478 self._warned_weight_size = False 

479 self.inner_product = inner_product 

480 self.state_scaling = state_scaling 

481 self.newton_max_it = newton_max_it 

482 self.newton_rtol = newton_rtol 

483 self.newton_atol = newton_atol 

484 self.stall_window = stall_window 

485 self.stall_rtol = stall_rtol 

486 self.max_outer_iterations = max_outer_iterations 

487 self.g_conv_limit = g_conv_limit 

488 self.line_search = line_search 

489 self.eta_threshold = eta_threshold 

490 self.min_snapshots = min_snapshots 

491 self.run_fom_every_evaluation = run_fom_every_evaluation 

492 

493 self.pod = None 

494 self.W = None 

495 self.scales = None 

496 self.last_solver = None 

497 self.adjoint = None 

498 

499 # ------------------------------------------------------------------ 

500 # Set-up and weights 

501 # ------------------------------------------------------------------ 

502 def set_up(self, model): 

503 """Build the inner-product weight and the POD on the model's 

504 current (undeformed) mesh. Call once the model's boundary conditions, 

505 function spaces and state exist. Collective.""" 

506 layout = model.u_vec.x.petsc_vec 

507 if self.state_scaling is None: 

508 self.scales = None 

509 elif isinstance(self.state_scaling, str): 

510 self.scales = np.asarray(model.state_scales(), dtype=float) 

511 else: 

512 self.scales = np.asarray(self.state_scaling, dtype=float) 

513 if self.scales is not None and self.scales.shape != (model.n_state,): 

514 raise ValueError("{} state scales for {} state components".format( 

515 self.scales.shape[0], model.n_state)) 

516 if self.scales is not None and not np.all(self.scales > 0): 

517 raise ValueError("state scales must be positive, got {}".format(self.scales)) 

518 

519 if self.W is not None: 

520 self.W.destroy() 

521 w = self._diagonal(model, mass_power=1.0, scale_power=-2.0) 

522 self.W = None if w is None else DiagonalWeight(w) 

523 if self.pod is not None: 

524 self.pod.destroy() 

525 self.pod = PODBasis(model.mesh_obj.mesh.comm, layout.getSize(), basis_mode=self.basis_mode, 

526 IP_matrix=self.W, max_rank=self.max_rank, qr_rank_tol=self.qr_rank_tol) 

527 PETSc.Sys.Print("Initialized POD ROM: basis_mode={}, rb_size={}, max_rank={}, n_dofs={}, " 

528 "inner product {}{}".format( 

529 self.basis_mode, self.rb_size, self.max_rank, layout.getSize(), 

530 self.inner_product, 

531 "" if self.scales is None else ", state scales {}".format(self.scales))) 

532 

533 def set_distance_sensitivities(self, norms): 

534 """Take the weights of the snapshot distance from the total-derivative 

535 norms of dv_sensitivity (the `norms` dict its probe passes to 

536 `on_norms`): ||dF/dp_j|| over the objective and constraints, normalized 

537 to mean 1. Unusable norms (non-finite, or all zero) keep the previous 

538 weights.""" 

539 if self.distance_weighting is None: 

540 return 

541 weights = norms['column_norms_relative'] if self.distance_weighting == 'total_derivative' \ 

542 else norms['column_norms'] 

543 if weights is None: 

544 # no output values in the derivative result 

545 PETSc.Sys.Print("ROM distance weighting: output values unavailable, using the raw " 

546 "total-derivative norms for this evaluation") 

547 weights = norms['column_norms'] 

548 weights = np.asarray(weights, dtype=float).ravel() 

549 if not np.all(np.isfinite(weights)) or not np.any(weights > 0.): 

550 PETSc.Sys.Print("ROM distance weighting: the total-derivative norms are all zero or " 

551 "not finite; keeping the previous weights") 

552 return 

553 self.distance_sensitivities = weights / weights.mean() 

554 inert = "" if self.basis_mode == 'local_weighted' else \ 

555 " -- NOT IN USE, basis_mode is '{}'".format(self.basis_mode) 

556 PETSc.Sys.Print("ROM distance weights ({}, {} parameters, mean-normalized): min {:.4e}, " 

557 "median {:.4e}, max {:.4e}{}".format( 

558 self.distance_weighting, weights.shape[0], 

559 self.distance_sensitivities.min(), np.median(self.distance_sensitivities), 

560 self.distance_sensitivities.max(), inert)) 

561 

562 def _distance_weights(self, n_parameters): 

563 """The per-parameter factors on the snapshot distance: parameter_scales 

564 times the output-sensitivity weights. None where there is neither, and 

565 the sensitivity part is dropped when it does not fit the parameter 

566 vector (e.g. alpha is not a design variable).""" 

567 scales = None if self.parameter_scales is None else np.asarray(self.parameter_scales, dtype=float) 

568 weights = self.distance_sensitivities 

569 if self.distance_weighting is None or weights is None: 

570 return scales 

571 if weights.shape[0] != n_parameters: 

572 if not self._warned_weight_size: 

573 PETSc.Sys.Print("ROM distance weighting: {} weights do not fit the {}-entry parameter " 

574 "vector; using the distance without them. They come from the design " 

575 "variables of the CSDL problem, which must be the snapshot " 

576 "parameters.".format(weights.shape[0], n_parameters)) 

577 self._warned_weight_size = True 

578 return scales 

579 return weights if scales is None else scales * weights 

580 

581 def _diagonal(self, model, mass_power, scale_power): 

582 """diag(M^mass_power D^scale_power) as a Vec in the state layout, on 

583 the model's current mesh (None when it is the identity).""" 

584 if self.inner_product == 'euclidean' and self.scales is None: 

585 return None 

586 V = model.functionspaces["V"] 

587 if self.inner_product == 'l2': 

588 d = diagonal_mass_inverse(V, model.forms.dx) 

589 arr = d.array 

590 arr[:] = arr ** (-mass_power) 

591 else: 

592 d = model.u_vec.x.petsc_vec.duplicate() 

593 d.set(1.0) 

594 arr = d.array 

595 if self.scales is not None: 

596 # owned entries come in blocks of n_state, one per cell 

597 arr *= np.tile(self.scales ** scale_power, arr.shape[0] // model.n_state) 

598 return d 

599 

600 @property 

601 def n_snapshots(self): 

602 return 0 if self.pod is None else self.pod.snapshot_matrix.n_snapshots 

603 

604 def snapshot_singular_values(self): 

605 sigma = None if self.pod is None else self.pod.snapshot_matrix.Sigma 

606 return None if sigma is None else sigma.getArray().copy() 

607 

608 # ------------------------------------------------------------------ 

609 # Snapshots and solve 

610 # ------------------------------------------------------------------ 

611 def add_snapshot(self, model, parameter_vector): 

612 """Add the model's current u_vec as the snapshot at parameter_vector. 

613 Returns whether it added a basis direction. Collective.""" 

614 added = self.pod.snapshot_matrix.process_and_append_to_snapshot_lists( 

615 model.u_vec.x.petsc_vec, np.asarray(parameter_vector, dtype=float)) 

616 self.pod.snapshot_matrix.print_info() 

617 return added 

618 

619 def discard_adjoint(self): 

620 """Drop the adjoint of the last accepted solve. Collective.""" 

621 if self.adjoint is not None: 

622 self.adjoint.destroy() 

623 self.adjoint = None 

624 

625 def solve(self, model, parameter_vector): 

626 """ 

627 The ROM solution at parameter_vector, starting from the model's 

628 current u_vec (projected onto the basis); u_vec holds it afterwards. 

629 Returns a ROMResult, or None when there are fewer than min_snapshots 

630 snapshots. When the solution is accepted, `adjoint` is its adjoint; 

631 any earlier one is discarded first. Collective. 

632 """ 

633 self.discard_adjoint() 

634 if self.n_snapshots < self.min_snapshots: 

635 PETSc.Sys.Print("ROM: {} snapshot(s) stored, {} needed; skipping the ROM solve".format( 

636 self.n_snapshots, self.min_snapshots)) 

637 return None 

638 t0 = perf_counter() 

639 comm = model.mesh_obj.mesh.comm 

640 self.pod.construct_basis(rb_size=self.rb_size, 

641 local_parametervector=np.asarray(parameter_vector, dtype=float), 

642 parameter_weights=self._distance_weights(len(parameter_vector)), 

643 weightfunction=self.weightfunction, 

644 n_nonzero_weights=self.n_nonzero_weights, 

645 cubic_cutoff=self.cubic_cutoff, 

646 layout_vec=model.u_vec.x.petsc_vec) 

647 if self.pod.basis is None or self.pod.basis.getSize()[1] == 0: 

648 PETSc.Sys.Print("ROM: empty POD basis; skipping the ROM solve") 

649 return None 

650 

651 model.set_up_solver() 

652 S = self._diagonal(model, mass_power=-0.5, scale_power=-1.0) 

653 solver = LSPGSolver(self.pod.basis, comm, W=None if self.W is None else self.W.w, S=S, 

654 mean=self.pod.mean, rtol=self.newton_rtol, atol=self.newton_atol, 

655 max_it=self.newton_max_it, stall_window=self.stall_window, 

656 stall_rtol=self.stall_rtol, line_search=self.line_search) 

657 # Up to max_outer_iterations passes, each restarted from the current 

658 # state; converged when the pass's last |g| is below g_conv_limit (the 

659 # LSPG stationarity residual -- the projected residual V^T r does not 

660 # vanish at the LSPG solution, so it is not tested) 

661 converged = False 

662 iterations = 0 

663 residual_norm = np.nan 

664 try: 

665 for _ in range(self.max_outer_iterations): 

666 with model.steady_residual(): 

667 its, _ = solver.solve(model) 

668 iterations += its 

669 residual_norm = model._assemble_physical_residual() 

670 if not np.isfinite(residual_norm): 

671 PETSc.Sys.Print("ROM: non-finite residual; stopping") 

672 break 

673 if solver.last_g_norm is not None and solver.last_g_norm < self.g_conv_limit: 

674 converged = True 

675 PETSc.Sys.Print("LSPG converged: |g| = {:.6e} < {:.1e}".format( 

676 solver.last_g_norm, self.g_conv_limit)) 

677 break 

678 else: 

679 PETSc.Sys.Print("ROM: {} LSPG passes without |g| < {:.1e} (last |g| {:.6e})".format( 

680 self.max_outer_iterations, self.g_conv_limit, solver.last_g_norm)) 

681 except BaseException: 

682 if S is not None: 

683 S.destroy() 

684 raise 

685 finally: 

686 solver.destroy() 

687 self.last_solver = solver 

688 solver.n_iterations = iterations 

689 

690 eta = model.relative_residual_norm(residual_norm) 

691 model.u_vec.x.scatter_forward() 

692 result = ROMResult(solution=model.u_vec.x.petsc_vec.getArray().copy(), eta=eta, 

693 residual_norm=residual_norm, converged=converged, 

694 accepted=bool(np.isfinite(eta) and eta < self.eta_threshold), 

695 iterations=iterations, basis_width=solver.rb_size, 

696 walltime=perf_counter() - t0) 

697 PETSc.Sys.Print("ROM relative residual norm eta: {} (||r||: {}); {} iterations, wall time {}".format( 

698 eta, residual_norm, iterations, result.walltime)) 

699 if result.accepted: 

700 # the basis stays valid until the next solve rebuilds it, which 

701 # discards this adjoint first 

702 self.adjoint = LSPGAdjoint(self.pod.basis, S, comm, parameter_vector, result.solution) 

703 elif S is not None: 

704 S.destroy() 

705 return result 

706 

707 def destroy(self): 

708 self.discard_adjoint() 

709 if self.pod is not None: 

710 self.pod.destroy() 

711 self.pod = None 

712 if self.W is not None: 

713 self.W.destroy() 

714 self.W = None