Coverage for /opt/hostedtoolcache/Python/3.11.10/x64/lib/python3.11/site-packages/pysagas/cfd/solver.py: 31%

143 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-10-30 04:27 +0000

1from __future__ import annotations 

2import os 

3import copy 

4import meshio 

5import numpy as np 

6import pandas as pd 

7from pysagas import banner 

8from abc import ABC, abstractmethod 

9from typing import List, Optional, Dict 

10from pysagas import Cell, FlowState, Vector 

11 

12 

13class AbstractFlowSolver(ABC): 

14 """Abstract interface for flow solvers.""" 

15 

16 method = None 

17 

18 @abstractmethod 

19 def __init__(self, **kwargs) -> None: 

20 pass 

21 

22 def __repr__(self) -> str: 

23 return f"PySAGAS {self.method} flow solver" 

24 

25 def __str__(self) -> str: 

26 return f"PySAGAS {self.method} flow solver" 

27 

28 @property 

29 @abstractmethod 

30 def method(self): 

31 # This is a placeholder for a class variable defining the solver method 

32 pass 

33 

34 @abstractmethod 

35 def solve(self, *args, **kwargs): 

36 pass 

37 

38 

39class FlowSolver(AbstractFlowSolver): 

40 """Base class for CFD flow solver.""" 

41 

42 def __init__( 

43 self, 

44 cells: List[Cell], 

45 freestream: Optional[FlowState] = None, 

46 verbosity: Optional[int] = 1, 

47 ) -> None: 

48 """Instantiates the flow solver. 

49 

50 Parameters 

51 ---------- 

52 cells : list[Cell] 

53 A list of the cells describing the geometry. 

54 

55 freestream : Flowstate, optional 

56 The free-stream flow state. The default is the freestream provided 

57 upon instantiation of the solver. 

58 

59 verbosity : int, optional 

60 The verbosity of the solver. The default is 1. 

61 """ 

62 

63 # TODO - allow providing a geometry parser, eg. tri file parser for Cart3D, 

64 # or a (yet to be implemented) STL parser. 

65 

66 self.cells = cells 

67 self.freestream = freestream 

68 self.verbosity = verbosity 

69 self._last_solve_freestream: FlowState = None 

70 self._last_sens_freestream: FlowState = None 

71 

72 # Results 

73 self.flow_result: FlowResults = None 

74 self.flow_sensitivity: SensitivityResults = None 

75 

76 # Print banner 

77 if self.verbosity > 0: 

78 banner() 

79 print(f"\033[4m{self.__repr__()}\033[0m".center(50, " ")) 

80 

81 def solve( 

82 self, 

83 freestream: Optional[FlowState] = None, 

84 mach: Optional[float] = None, 

85 aoa: Optional[float] = None, 

86 ) -> bool: 

87 """Run the flow solver. 

88 

89 Parameters 

90 ---------- 

91 freestream : Flowstate, optional 

92 The free-stream flow state. The default is the freestream provided 

93 upon instantiation of the solver. 

94 

95 mach : float, optional 

96 The free-stream Mach number. The default is that specified in 

97 the freestream flow state. 

98 

99 aoa : float, optional 

100 The free-stream angle of attack. The default is that specified in 

101 the freestream flow state. 

102 

103 Returns 

104 ------- 

105 result : FlowResults 

106 The flow results. 

107 

108 Raises 

109 ------ 

110 Exception : when no freestream can be found. 

111 """ 

112 

113 if not freestream: 

114 # No freestream conditions specified 

115 if not self.freestream: 

116 # No conditions provided on instantiation either 

117 raise Exception("Please specify freestream conditions.") 

118 else: 

119 # Use nominal freestream as base 

120 freestream = copy.copy(self.freestream) 

121 if mach: 

122 # Update mach number 

123 freestream._M = mach 

124 

125 if aoa: 

126 # Update flow direction 

127 flow_direction = Vector(1, 1 * np.tan(np.deg2rad(aoa)), 0).unit 

128 freestream.direction = flow_direction 

129 

130 else: 

131 # Solve-specific freestream conditions provided 

132 if mach or aoa: 

133 print("Using freestream provided; ignoring Mach/aoa provided.") 

134 

135 # Check if already solved 

136 if self._last_solve_freestream and freestream == self._last_solve_freestream: 

137 return True 

138 else: 

139 # Update last solve freestream and continue 

140 self._last_solve_freestream = freestream 

141 return False 

142 

143 def solve_sens( 

144 self, 

145 freestream: Optional[FlowState] = None, 

146 mach: Optional[float] = None, 

147 aoa: Optional[float] = None, 

148 ) -> SensitivityResults: 

149 """Run the flow solver to obtain sensitivity information. 

150 

151 Parameters 

152 ---------- 

153 freestream : Flowstate, optional 

154 The free-stream flow state. The default is the freestream provided 

155 upon instantiation of the solver. 

156 

157 Mach : float, optional 

158 The free-stream Mach number. The default is that specified in 

159 the freestream flow state. 

160 

161 aoa : float, optional 

162 The free-stream angle of attack. The default is that specified in 

163 the freestream flow state. 

164 

165 Returns 

166 ------- 

167 SensitivityResults 

168 

169 Raises 

170 ------ 

171 Exception : when no freestream can be found. 

172 """ 

173 if not freestream: 

174 # No freestream conditions specified 

175 if not self.freestream: 

176 # No conditions provided on instantiation either 

177 raise Exception("Please specify freestream conditions.") 

178 else: 

179 # Use nominal freestream as base 

180 freestream = copy.copy(self.freestream) 

181 if mach: 

182 # Update mach number 

183 freestream._M = mach 

184 

185 if aoa: 

186 # Update flow direction 

187 flow_direction = Vector(1, 1 * np.tan(np.deg2rad(aoa)), 0).unit 

188 freestream.direction = flow_direction 

189 

190 else: 

191 # Solve-specific freestream conditions provided 

192 if mach or aoa: 

193 print("Using freestream provided; ignoring Mach/aoa provided.") 

194 

195 # Check if already solved 

196 if self._last_sens_freestream and freestream == self._last_sens_freestream: 

197 return True 

198 else: 

199 # Update last solve freestream and continue 

200 self._last_sens_freestream = freestream 

201 return False 

202 

203 def save(self, name: str, attributes: Dict[str, list]): 

204 """Save the solution to VTK file format. Note that the 

205 geometry mesh must have been loaded from a parser which 

206 includes connectivity information (eg. PyMesh or TRI). 

207 

208 Parameters 

209 ---------- 

210 name : str 

211 The filename prefix of the desired output file. 

212 

213 attributes : Dict[str, list] 

214 The attributes dictionary used to initialise the file writer. 

215 Note that this is not required when calling `solve` from a 

216 child FlowSolver. 

217 """ 

218 # Construct vertices and faces 

219 faces = [] 

220 vertex_faces = {} 

221 for cell in self.cells: 

222 # Get faces 

223 faces.append(cell._face_ids) 

224 

225 # Get attributes 

226 for a in attributes: 

227 attributes[a].append(cell.attributes.get(a)) 

228 

229 # Get vertices 

230 for i, vid in enumerate(cell._face_ids): 

231 vertex_faces[vid] = cell.vertices[i] 

232 

233 # Sort vertices 

234 sorted_vertices = dict(sorted(vertex_faces.items())) 

235 

236 # Note: vertex attributes can be treated the same way as 

237 # the vertices - they must be sorted. 

238 

239 # Finally 

240 vertices = np.array([v for v in sorted_vertices.values()]) 

241 faces = np.array(faces) 

242 

243 # Convert mesh object with attributes 

244 mesh_obj = meshio.Mesh( 

245 points=vertices, 

246 cells=[("triangle", faces)], 

247 cell_data={k: [v] for k, v in attributes.items()}, 

248 ) 

249 mesh_obj.write(f"{name}.vtk") 

250 

251 @staticmethod 

252 def body_to_wind(v: Vector, aoa: float): 

253 """Converts a vector from body axes to wind axes. 

254 

255 Parameters 

256 ---------- 

257 v : Vector 

258 The result to transform. 

259 

260 aoa : float 

261 The angle of attack, specified in degrees. 

262 

263 Returns 

264 -------- 

265 r : Vector 

266 The transformed result. 

267 """ 

268 # TODO - use euler matrices 

269 A = v.x # axial 

270 N = v.y # normal 

271 

272 L = N * np.cos(np.deg2rad(aoa)) - A * np.sin(np.deg2rad(aoa)) 

273 D = N * np.sin(np.deg2rad(aoa)) + A * np.cos(np.deg2rad(aoa)) 

274 

275 # Construct new result 

276 r = Vector(x=D, y=L, z=v.z) 

277 

278 return r 

279 

280 def sweep(self, aoa_range: list[float], mach_range: list[float]) -> pd.DataFrame: 

281 """Evaluate the aerodynamic coefficients over a sweep of angle 

282 of attack and mach numbers. 

283 

284 Parameters 

285 ----------- 

286 aoa_range : list[float] 

287 The angles of attack to evaluate at, specified in degrees. 

288 

289 mach_range : list[float] 

290 The Mach numbers to evaluate at. 

291 

292 Returns 

293 -------- 

294 results : pd.DataFrame 

295 A Pandas DataFrame of the aerodynamic coefficients at each angle of 

296 attack and Mach number combination. 

297 """ 

298 # TODO - add bool to evaluate gradients on the sweep 

299 # Run sweep 

300 i = 0 

301 results = pd.DataFrame() 

302 for aoa in aoa_range: 

303 for mach in mach_range: 

304 flowresult: FlowResults = self.solve(aoa=aoa, Mach=mach) 

305 CL, CD, Cm = flowresult.coefficients() 

306 

307 coefficients_df = pd.DataFrame( 

308 data={"aoa": aoa, "mach": mach, "CL": CL, "CD": CD, "Cm": Cm}, 

309 index=[i], 

310 ) 

311 

312 # Append results 

313 results = pd.concat( 

314 [ 

315 results, 

316 coefficients_df, 

317 ] 

318 ) 

319 

320 # Iterate index 

321 i += 1 

322 

323 return results 

324 

325 

326class FlowResults: 

327 """A class containing the aerodynamic force and moment 

328 information with respect to design parameters. 

329 

330 Attributes 

331 ---------- 

332 freestream : FlowState 

333 The freestream flow state. 

334 

335 net_force : pd.DataFrame 

336 The net force in cartesian coordinate frame (x,y,z). 

337 

338 m_sense : pd.DataFrame 

339 The net moment in cartesian coordinate frame (x,y,z). 

340 """ 

341 

342 def __init__( 

343 self, freestream: FlowState, net_force: Vector, net_moment: Vector 

344 ) -> None: 

345 self.freestream = freestream 

346 self.net_force = net_force 

347 self.net_moment = net_moment 

348 

349 # Calculate angle of attack 

350 self.aoa = np.rad2deg( 

351 np.arctan(freestream.direction.y / freestream.direction.x) 

352 ) 

353 

354 def __str__(self) -> str: 

355 return f"Net force = {self.net_force} N" 

356 

357 def __repr__(self) -> str: 

358 return self.__str__() 

359 

360 def coefficients(self, A_ref: Optional[float] = 1.0, c_ref: Optional[float] = 1.0): 

361 """Calculate the aerodynamic coefficients CL, CD and Cm. 

362 

363 Parameters 

364 ----------- 

365 A_ref : float, optional 

366 The reference area (m^2). The default is 1 m^2. 

367 

368 c_ref : float, optional 

369 The reference length (m). The default is 1 m. 

370 

371 Returns 

372 ------- 

373 C_L, C_D, C_m 

374 """ 

375 w = FlowSolver.body_to_wind(v=self.net_force, aoa=self.aoa) 

376 C_L = w.y / (self.freestream.q * A_ref) 

377 C_D = w.x / (self.freestream.q * A_ref) 

378 

379 mw = FlowSolver.body_to_wind(v=self.net_moment, aoa=self.aoa) 

380 C_m = -mw.z / (self.freestream.q * A_ref * c_ref) 

381 return C_L, C_D, C_m 

382 

383 @property 

384 def coef(self): 

385 CL, CD, Cm = self.coefficients() 

386 print(f"CL = {CL:.3f}\nCD = {CD:.3f}\nCm = {Cm:.3f}") 

387 

388 

389class SensitivityResults: 

390 """A class containing the aerodynamic force and moment sensitivity 

391 information with respect to design parameters. 

392 

393 Attributes 

394 ---------- 

395 freestream : FlowState 

396 The freestream flow state. 

397 

398 f_sense : pd.DataFrame 

399 The force sensitivities in cartesian coordinate frame (x,y,z). 

400 

401 m_sense : pd.DataFrame 

402 The moment sensitivities in cartesian coordinate frame (x,y,z). 

403 """ 

404 

405 def __init__( 

406 self, 

407 f_sens: pd.DataFrame, 

408 m_sens: pd.DataFrame, 

409 freestream: Optional[FlowState] = None, 

410 ) -> None: 

411 self.f_sens = f_sens 

412 self.m_sens = m_sens 

413 self.freestream = freestream 

414 

415 def __str__(self) -> str: 

416 s1 = f"Force sensitivties:\n{self.f_sens}" 

417 s2 = f"Moment sensitivties:\n{self.m_sens}" 

418 

419 return f"{s1}\n\n{s2}" 

420 

421 def __repr__(self) -> str: 

422 return self.__str__() 

423 

424 def coefficients( 

425 self, A_ref: Optional[float] = 1.0, c_ref: Optional[float] = 1.0 

426 ) -> tuple[pd.DataFrame, pd.DataFrame]: 

427 """Calculate the aerodynamic coefficients CL, CD and Cm. 

428 

429 Parameters 

430 ----------- 

431 A_ref : float, optional 

432 The reference area (m^2). The default is 1 m^2. 

433 

434 c_ref : float, optional 

435 The reference length (m). The default is 1 m. 

436 

437 Returns 

438 ------- 

439 A tuple (cf_sens, cm_sens) containing the force and moment coefficient sensitivities. 

440 """ 

441 # Non-dimensionalise 

442 cf_sens: pd.DataFrame = self.f_sens / (self.freestream.q * A_ref) 

443 cm_sens: pd.DataFrame = self.m_sens / (self.freestream.q * A_ref * c_ref) 

444 

445 # Translate to aero frame 

446 # TODO - review if this might swap value order on overwrite of columns 

447 aoa = self.freestream.aoa 

448 cf_sens["dFy/dp"] = cf_sens["dFy/dp"] * np.cos(np.deg2rad(aoa)) - cf_sens[ 

449 "dFx/dp" 

450 ] * np.sin(np.deg2rad(aoa)) 

451 cf_sens["dFx/dp"] = cf_sens["dFy/dp"] * np.sin(np.deg2rad(aoa)) + cf_sens[ 

452 "dFx/dp" 

453 ] * np.cos(np.deg2rad(aoa)) 

454 

455 # Rename columns 

456 cf_sens.rename( 

457 columns={"dFx/dp": "dCD", "dFy/dp": "dCL", "dFz/dp": "dCZ"}, inplace=True 

458 ) 

459 # cm_sens.rename(columns={"dMx/dp": "dCl", "dMy/dp": "dCn", "dMz/dp": "dCm"}, inplace=True) 

460 

461 return cf_sens, cm_sens