Coverage for /opt/hostedtoolcache/Python/3.11.10/x64/lib/python3.11/site-packages/hypervehicle/vehicle.py: 55%
176 statements
« prev ^ index » next coverage.py v7.6.4, created at 2024-10-29 02:51 +0000
« prev ^ index » next coverage.py v7.6.4, created at 2024-10-29 02:51 +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)
15class Vehicle:
16 ALLOWABLE_COMPONENTS = [
17 FIN_COMPONENT,
18 WING_COMPONENT,
19 SWEPT_COMPONENT,
20 REVOLVED_COMPONENT,
21 COMPOSITE_COMPONENT,
22 ]
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
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
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
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})"
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
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
71 self.verbosity = verbosity
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.
87 Parameters
88 ----------
89 component : Component
90 The component to add.
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.
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.
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.
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.
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.
120 transformations : List[Tuple[str, Any]], optional
121 A list of transformations to apply to the nominal component. The
122 default is None.
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.
129 ghost : bool, optional
130 Add a ghost component. When True, this component will be excluded
131 from the files written to STL.
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)
146 # Add component reflections
147 if reflection_axis is not None:
148 component._reflection_axis = reflection_axis
149 component._append_reflection = append_reflection
151 # Add component curvature functions
152 if curvatures is not None:
153 component._curvatures = curvatures
155 # Add component clustering
156 if clustering is not None:
157 component.add_clustering_options(**clustering)
159 # Add transformations
160 if transformations is not None:
161 component._transformations = transformations
163 # Add modifier function
164 if modifier_function is not None:
165 component._modifier_function = modifier_function
167 # Ghost component
168 component._ghost = ghost
170 # Add component
171 self.components.append(component)
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
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
194 if self.verbosity > 1:
195 print(f"Added new {component.componenttype} component ({name}).")
197 else:
198 raise Exception(f"Unrecognised component type: {component.componenttype}")
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.")
206 for component in self.components:
207 if self.verbosity > 1:
208 print(f" Generating patches for {component.componenttype} component.")
210 # Generate component patches
211 component.generate_patches()
213 # Apply the modifier function
214 component.apply_modifier()
216 # Add curvature
217 component.curve()
219 # Add offset angle to correct curve-induced AoA
220 component.rotate(angle=self.vehicle_angle_offset)
222 # Reflect
223 component.reflect()
225 # Apply transformations
226 component.transform()
228 # Set generated boolean to True
229 self._generated = True
231 # Apply any vehicle transformations
232 if self._vehicle_transformations:
233 self.transform(self._vehicle_transformations)
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 )
242 if self.verbosity > 0:
243 print("All component patches generated.")
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.
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")]`.
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
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.
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
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.")
289 # Check input
290 if isinstance(transformations, tuple):
291 # Coerce into list
292 transformations = [transformations]
294 # Apply transformations
295 for component in self.components:
296 for transform in transformations:
297 func = getattr(component, transform[0])
298 func(*transform[1:])
300 # Reset any meshes generated from un-transformed patches
301 component.surfaces = None
302 component.mesh = None
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.
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.
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.
324 See Also
325 --------
326 utilities.merge_stls
327 """
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}.")
336 types_generated = {}
337 for component in self.components:
338 # Get component count
339 no = types_generated.get(component.componenttype, 0)
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"
354 if self.verbosity > 0:
355 print(f" Writing: {stl_name} ", end="\r")
357 component.to_stl(stl_name)
359 # Update component count
360 types_generated[component.componenttype] = no + 1
362 # Write geometric analysis results to csv too
363 if self.analysis_results:
364 if not prefix:
365 prefix = self.name
367 # Make analysis results directory
368 properties_dir = f"{prefix}_properties"
369 if not os.path.exists(properties_dir):
370 os.mkdir(properties_dir)
372 # Write volume and mass to file
373 self._volmass.to_csv(os.path.join(properties_dir, f"{prefix}_volmass.csv"))
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 )
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 )
385 # Write user-defined vehicle properties
386 if self.properties:
387 if not prefix:
388 prefix = self.name
390 # Make analysis results directory
391 properties_dir = f"{prefix}_properties"
392 if not os.path.exists(properties_dir):
393 os.mkdir(properties_dir)
395 # Write properties to file
396 pd.Series(self.properties).to_csv(
397 os.path.join(properties_dir, f"{prefix}_properties.csv")
398 )
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 )
408 else:
409 # Merge all components
410 if not prefix:
411 prefix = self.name
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)
421 if self.verbosity > 0:
422 print("\rAll components written to STL file format.", end="\n")
424 def analyse(self, densities: dict) -> Tuple:
425 """Evaluates the mesh properties of the vehicle instance.
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.
435 Returns
436 -------
437 total_volume : float
438 The total volume.
440 total_mass : float
441 The toal mass.
443 composite_cog : np.array
444 The composite center of gravity.
446 composite_inertia : np.array
447 The composite mass moment of inertia.
448 """
449 from hypervehicle.utilities import assess_inertial_properties
451 vehicle_properties, component_properties = assess_inertial_properties(
452 vehicle=self, component_densities=densities
453 )
455 # Unpack vehicle properties
456 for k, v in vehicle_properties.items():
457 setattr(self, k, v)
459 # Save component properties
460 self.component_properties = component_properties
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 )
480 return self.volume, self.mass, self.cog, self.moi
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
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