Coverage for /opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/hypervehicle/vehicle.py: 55%

176 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-25 22:58 +0000

1import os 

2import pandas as pd 

3from hypervehicle import utilities 

4from hypervehicle.components.component import Component 

5from typing import List, Tuple, Callable, Dict, Any, Optional, Union 

6from hypervehicle.components.constants import ( 

7 FIN_COMPONENT, 

8 WING_COMPONENT, 

9 COMPOSITE_COMPONENT, 

10 SWEPT_COMPONENT, 

11 REVOLVED_COMPONENT, 

12) 

13 

14 

15class Vehicle: 

16 ALLOWABLE_COMPONENTS = [ 

17 FIN_COMPONENT, 

18 WING_COMPONENT, 

19 SWEPT_COMPONENT, 

20 REVOLVED_COMPONENT, 

21 COMPOSITE_COMPONENT, 

22 ] 

23 

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

25 # Vehicle attributes 

26 self.components: List[Component] = [] 

27 self.name = "vehicle" 

28 self.vehicle_angle_offset: float = 0 

29 self.verbosity = 1 

30 self.properties = {} # user-defined vehicle properties from generator 

31 

32 # Analysis attributes 

33 self.analysis_results = None 

34 self.component_properties = None 

35 self._volmass = None 

36 self.volume = None 

37 self.mass = None 

38 self.area = None 

39 self.cog = None 

40 self.moi = None 

41 

42 # Internal attributes 

43 self._generated = False 

44 self._component_counts = {} 

45 self._enumerated_components = {} 

46 self._named_components: dict[str, Component] = {} 

47 self._vehicle_transformations = [] 

48 self._analyse_on_generation = None 

49 

50 def __repr__(self): 

51 basestr = self.__str__() 

52 if len(self.components) > 0: 

53 compstr = ", ".join( 

54 [f"{e[1]} {e[0]}" for e in self._component_counts.items()] 

55 ) 

56 else: 

57 compstr = "no components" 

58 return f"{basestr} ({compstr})" 

59 

60 def __str__(self) -> str: 

61 vstr = f"Parameterised {self.name}" 

62 if len(self.components) > 0: 

63 vstr += f" with {len(self.components)} components" 

64 return vstr 

65 

66 def configure(self, name: str = None, verbosity: int = 1): 

67 """Configure the Vehicle instance.""" 

68 if name is not None: 

69 self.name = name 

70 

71 self.verbosity = verbosity 

72 

73 def add_component( 

74 self, 

75 component: Component, 

76 name: str = None, 

77 reflection_axis: str = None, 

78 append_reflection: bool = True, 

79 curvatures: List[Tuple[str, Callable, Callable]] = None, 

80 clustering: Dict[str, Callable] = None, 

81 transformations: List[Tuple[str, Any]] = None, 

82 modifier_function: Optional[Callable] = None, 

83 ghost: Optional[bool] = False, 

84 ) -> None: 

85 """Adds a new component to the vehicle. 

86 

87 Parameters 

88 ---------- 

89 component : Component 

90 The component to add. 

91 

92 name : str, optional 

93 The name to assign to this component. If provided, it will be used when 

94 writing to STL. The default is None. 

95 

96 reflection_axis : str, optional 

97 Include a reflection of the component about the axis specified 

98 (eg. 'x', 'y' or 'z'). The default is None. 

99 

100 append_reflection : bool, optional 

101 When reflecting a new component, add the reflection to the existing 

102 component, rather than making it a new component. This is recommended 

103 when the combined components will form a closed mesh, but if the 

104 components will remain as two isolated bodies, a new component should 

105 be created (ie. append_reflection=False). In this case, you can use 

106 copy.deepcopy to make a copy of the reflected component when adding it 

107 to the vehicle. See the finner example in the hypervehicle hangar. 

108 The default is True. 

109 

110 curvatures : List[Tuple[str, Callable, Callable]], optional 

111 A list of the curvatures to apply to the component being added. 

112 This list contains a tuple for each curvature. Each curvatue 

113 is defined by (axis, curve_func, curve_func_derivative). 

114 The default is None. 

115 

116 clustering : Dict[str, Callable], optional 

117 Optionally provide clustering options for the stl meshes. See 

118 parametricSurfce2stl for more information. The default is None. 

119 

120 transformations : List[Tuple[str, Any]], optional 

121 A list of transformations to apply to the nominal component. The 

122 default is None. 

123 

124 modifier_function : Callable, optional 

125 A function which accepts x,y,z coordinates and returns a Vector3 

126 object with a positional offset. This function is used with an 

127 OffsetPatchFunction. The default is None. 

128 

129 ghost : bool, optional 

130 Add a ghost component. When True, this component will be excluded 

131 from the files written to STL. 

132 

133 See Also 

134 -------- 

135 Vehicle.add_vehicle_transformations 

136 """ 

137 if component.componenttype in Vehicle.ALLOWABLE_COMPONENTS: 

138 # Overload component verbosity 

139 if self.verbosity == 0: 

140 # Zero verbosity 

141 component.verbosity = 0 

142 else: 

143 # Use max of vehicle and component verbosity 

144 component.verbosity = max(component.verbosity, self.verbosity) 

145 

146 # Add component reflections 

147 if reflection_axis is not None: 

148 component._reflection_axis = reflection_axis 

149 component._append_reflection = append_reflection 

150 

151 # Add component curvature functions 

152 if curvatures is not None: 

153 component._curvatures = curvatures 

154 

155 # Add component clustering 

156 if clustering is not None: 

157 component.add_clustering_options(**clustering) 

158 

159 # Add transformations 

160 if transformations is not None: 

161 component._transformations = transformations 

162 

163 # Add modifier function 

164 if modifier_function is not None: 

165 component._modifier_function = modifier_function 

166 

167 # Ghost component 

168 component._ghost = ghost 

169 

170 # Add component 

171 self.components.append(component) 

172 

173 # Add component count 

174 component_count = self._component_counts.get(component.componenttype, 0) + 1 

175 self._component_counts[component.componenttype] = component_count 

176 self._enumerated_components[ 

177 f"{component.componenttype}_{component_count}" 

178 ] = component 

179 

180 # Process component name 

181 if name is not None: 

182 # Assign component name 

183 component.name = name 

184 else: 

185 # No name provided, check component name 

186 if component.name: 

187 # Use component name 

188 name = component.name 

189 else: 

190 # Generate default name 

191 name = f"{component.componenttype}_{component_count}" 

192 self._named_components[name] = component 

193 

194 if self.verbosity > 1: 

195 print(f"Added new {component.componenttype} component ({name}).") 

196 

197 else: 

198 raise Exception(f"Unrecognised component type: {component.componenttype}") 

199 

200 def generate(self): 

201 """Generate all components of the vehicle.""" 

202 if self.verbosity > 0: 

203 utilities.print_banner() 

204 print("Generating component patches.") 

205 

206 for component in self.components: 

207 if self.verbosity > 1: 

208 print(f" Generating patches for {component.componenttype} component.") 

209 

210 # Generate component patches 

211 component.generate_patches() 

212 

213 # Apply the modifier function 

214 component.apply_modifier() 

215 

216 # Add curvature 

217 component.curve() 

218 

219 # Add offset angle to correct curve-induced AoA 

220 component.rotate(angle=self.vehicle_angle_offset) 

221 

222 # Reflect 

223 component.reflect() 

224 

225 # Apply transformations 

226 component.transform() 

227 

228 # Set generated boolean to True 

229 self._generated = True 

230 

231 # Apply any vehicle transformations 

232 if self._vehicle_transformations: 

233 self.transform(self._vehicle_transformations) 

234 

235 # Run analysis 

236 if self._analyse_on_generation: 

237 analysis_results = self.analyse(self._analyse_on_generation) 

238 self.analysis_results = dict( 

239 zip(("volume", "mass", "cog", "moi"), analysis_results) 

240 ) 

241 

242 if self.verbosity > 0: 

243 print("All component patches generated.") 

244 

245 def add_vehicle_transformations( 

246 self, transformations: List[Tuple[str, Any]] 

247 ) -> None: 

248 """Add transformations to apply to the vehicle after running generate(). 

249 Each transformation in the list should be a tuple of the form 

250 (transform_type, *args), where transform_type can be "rotate", or 

251 "translate". Note that transformations can be chained. 

252 

253 Extended Summary 

254 ---------------- 

255 - "rotate" : rotate the entire vehicle. The *args for rotate are 

256 angle (float) and axis (str). For example: `[("rotate", 180, "x"), 

257 ("rotate", 90, "y")]`. 

258 

259 - "translate" : translate the entire vehicle. The *args for translate 

260 includes the translational offset, specified either as a function (Callable), 

261 or as Vector3 object. 

262 """ 

263 # Check input 

264 if isinstance(transformations, tuple): 

265 # Coerce into list 

266 transformations = [transformations] 

267 self._vehicle_transformations += transformations 

268 

269 def analyse_after_generating(self, densities: Dict[str, Any]) -> None: 

270 """Run the vehicle analysis method immediately after generating 

271 patches. Results will be saved to the analysis_results attribute of 

272 the vehicle. 

273 

274 Parameters 

275 ---------- 

276 densities : Dict[str, Any] 

277 A dictionary containing the effective densities for each component. 

278 Note that the keys of the dict must match the keys of 

279 vehicle._named_components. These keys will be consistent with any 

280 name tags assigned to components. 

281 """ 

282 self._analyse_on_generation = densities 

283 

284 def transform(self, transformations: List[Tuple[str, Any]]) -> None: 

285 """Transform vehicle by applying the tranformations.""" 

286 if not self._generated: 

287 raise Exception("Vehicle has not been generated yet.") 

288 

289 # Check input 

290 if isinstance(transformations, tuple): 

291 # Coerce into list 

292 transformations = [transformations] 

293 

294 # Apply transformations 

295 for component in self.components: 

296 for transform in transformations: 

297 func = getattr(component, transform[0]) 

298 func(*transform[1:]) 

299 

300 # Reset any meshes generated from un-transformed patches 

301 component.surfaces = None 

302 component.mesh = None 

303 

304 def to_stl(self, prefix: str = None, merge: Union[bool, List[str]] = False) -> None: 

305 """Writes the vehicle components to STL file. If analysis results are 

306 present, they will also be written to file, either as CSV, or using 

307 the Numpy tofile method. 

308 

309 Parameters 

310 ---------- 

311 prefix : str, optional 

312 The prefix to use when saving components to STL. Note that if 

313 components have been individually assigned name tags, the prefix 

314 provided will take precedence. If no prefix is specified, and no 

315 component name tag is available, the Vehicle name will be used. 

316 The default is None. 

317 

318 merge : [bool, list[str]], optional 

319 Merge components of the vehicle into a single STL file. The merge 

320 argument can either be a boolean (with True indicating to merge all 

321 components of the vehicle), or a list of the component names to 

322 merge. This functionality depends on PyMesh. The default is False. 

323 

324 See Also 

325 -------- 

326 utilities.merge_stls 

327 """ 

328 

329 if self.verbosity > 0: 

330 s = "Writing vehicle components to STL" 

331 if prefix: 

332 print(f"{s}, with prefix '{prefix}'.") 

333 else: 

334 print(f"{s}.") 

335 

336 types_generated = {} 

337 for component in self.components: 

338 # Get component count 

339 no = types_generated.get(component.componenttype, 0) 

340 

341 # Write component to stl 

342 if prefix: 

343 # Use prefix provided 

344 if component.name: 

345 stl_name = f"{prefix}-{component.name}.stl" 

346 else: 

347 stl_name = f"{prefix}-{component.componenttype}-{no}.stl" 

348 elif component.name: 

349 stl_name = f"{component.name}.stl" 

350 else: 

351 # No prefix or component name, use vehicle name as fallback 

352 stl_name = f"{self.name}-{component.componenttype}-{no}.stl" 

353 

354 if self.verbosity > 0: 

355 print(f" Writing: {stl_name} ", end="\r") 

356 

357 component.to_stl(stl_name) 

358 

359 # Update component count 

360 types_generated[component.componenttype] = no + 1 

361 

362 # Write geometric analysis results to csv too 

363 if self.analysis_results: 

364 if not prefix: 

365 prefix = self.name 

366 

367 # Make analysis results directory 

368 properties_dir = f"{prefix}_properties" 

369 if not os.path.exists(properties_dir): 

370 os.mkdir(properties_dir) 

371 

372 # Write volume and mass to file 

373 self._volmass.to_csv(os.path.join(properties_dir, f"{prefix}_volmass.csv")) 

374 

375 # Write c.o.g. to file 

376 self.analysis_results["cog"].tofile( 

377 os.path.join(properties_dir, f"{prefix}_cog.txt"), sep=", " 

378 ) 

379 

380 # Write M.O.I. to file 

381 self.analysis_results["moi"].tofile( 

382 os.path.join(properties_dir, f"{prefix}_moi.txt"), sep=", " 

383 ) 

384 

385 # Write user-defined vehicle properties 

386 if self.properties: 

387 if not prefix: 

388 prefix = self.name 

389 

390 # Make analysis results directory 

391 properties_dir = f"{prefix}_properties" 

392 if not os.path.exists(properties_dir): 

393 os.mkdir(properties_dir) 

394 

395 # Write properties to file 

396 pd.Series(self.properties).to_csv( 

397 os.path.join(properties_dir, f"{prefix}_properties.csv") 

398 ) 

399 

400 # Merge STL components 

401 if merge: 

402 if isinstance(merge, list): 

403 # Merge specified components 

404 raise NotImplementedError( 

405 "Merging components by name not yet implemented." 

406 ) 

407 

408 else: 

409 # Merge all components 

410 if not prefix: 

411 prefix = self.name 

412 

413 # Get component names (excluding ghost components) 

414 filenames = [] 

415 for name, comp in self._named_components.items(): 

416 if not comp._ghost: 

417 # Append 

418 filenames.append(f"{name}.stl") 

419 utilities.merge_stls(stl_files=filenames, name=prefix) 

420 

421 if self.verbosity > 0: 

422 print("\rAll components written to STL file format.", end="\n") 

423 

424 def analyse(self, densities: dict) -> Tuple: 

425 """Evaluates the mesh properties of the vehicle instance. 

426 

427 Parameters 

428 ---------- 

429 densities : Dict[str, float] 

430 A dictionary containing the effective densities for each component. 

431 Note that the keys of the dict must match the keys of 

432 vehicle._named_components. These keys will be consistent with any 

433 name tags assigned to components. 

434 

435 Returns 

436 ------- 

437 total_volume : float 

438 The total volume. 

439 

440 total_mass : float 

441 The toal mass. 

442 

443 composite_cog : np.array 

444 The composite center of gravity. 

445 

446 composite_inertia : np.array 

447 The composite mass moment of inertia. 

448 """ 

449 from hypervehicle.utilities import assess_inertial_properties 

450 

451 vehicle_properties, component_properties = assess_inertial_properties( 

452 vehicle=self, component_densities=densities 

453 ) 

454 

455 # Unpack vehicle properties 

456 for k, v in vehicle_properties.items(): 

457 setattr(self, k, v) 

458 

459 # Save component properties 

460 self.component_properties = component_properties 

461 

462 # Save summary of volume and mass results 

463 component_vm = pd.DataFrame( 

464 {k: component_properties[k] for k in ["mass", "volume", "area"]} 

465 ) 

466 self._volmass = pd.concat( 

467 [ 

468 component_vm, 

469 pd.DataFrame( 

470 data={ 

471 "mass": self.mass, 

472 "volume": self.volume, 

473 "area": vehicle_properties["area"], 

474 }, 

475 index=["vehicle"], 

476 ), 

477 ] 

478 ) 

479 

480 return self.volume, self.mass, self.cog, self.moi 

481 

482 def add_property(self, name: str, value: float): 

483 """Add a named property to the vehicle. Currently only supports 

484 float property types. 

485 """ 

486 self.properties[name] = value 

487 

488 def get_non_ghost_components(self) -> dict[str, Component]: 

489 """Returns all non-ghost components.""" 

490 non_ghost = {} 

491 for name, comp in self._named_components.items(): 

492 if not comp._ghost: 

493 # Append 

494 non_ghost[name] = comp 

495 return non_ghost