symcad.core.SymPart
1#!/usr/bin/env python3 2# Copyright (C) 2022, Will Hedgecock 3# 4# This program is free software: you can redistribute it and/or modify 5# it under the terms of the GNU General Public License as published by 6# the Free Software Foundation, either version 3 of the License, or 7# (at your option) any later version. 8# 9# This program is distributed in the hope that it will be useful, 10# but WITHOUT ANY WARRANTY; without even the implied warranty of 11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12# GNU General Public License for more details. 13# 14# You should have received a copy of the GNU General Public License 15# along with this program. If not, see <http://www.gnu.org/licenses/>. 16 17from __future__ import annotations 18from .ML import NeuralNet 19from .CAD import ModeledCad, ScriptedCad 20from .Coordinate import Coordinate 21from .Geometry import Geometry 22from .Rotation import Rotation 23from typing import Callable, Dict, List, Literal 24from typing import Optional, Tuple, TypeVar, Union 25from copy import deepcopy 26from sympy import Expr 27import abc 28 29SymPartSub = TypeVar('SymPartSub', bound='SymPart') 30 31class SymPart(metaclass=abc.ABCMeta): 32 """Symbolic part base class from which all SymParts inherit. 33 34 Defines the interface to a set of abstract properties that all SymParts possess, including 35 mass, material volume, displaced volume, surface area, centroid, centers of gravity and 36 buouyancy, length, width, and height, among others. These properties may be concrete or 37 symbolic, and they represent the external interface that is expected to be used when 38 accessing the physical properties of a given SymPart. 39 40 When creating a new SymPart, its geometry and global placement will be treated as symbolic 41 parameters, and its global orientation will be assumed to be 0 such that the SymPart does not 42 rotate in space. These assumptions can be overridden using the various `set_*` methods of the 43 SymPart object. Attachment points can be defined to indicate areas on a SymPart that are able 44 to rigidly attach to other SymParts, or connection ports can be defined to indicate areas that 45 are able to flexibly and/or non-mechanically connect to other SymParts. 46 47 By default, a SymPart is assumed to be environmentally exposed and thus contribute to such 48 geometric properties as the displaced volume. If a SymPart is not exposed, for example a dry 49 component inside of a pressurized container, this should be specified by calling the 50 `set_unexposed()` method on the SymPart object. 51 52 The local coordinate space of a SymPart is defined with its origin at the front, center, 53 bottom of the part, where the x-axis extends positively from its front to its rear, the y-axis 54 extends positively from the xz-plane to the right of the part when looking from the positive 55 x-axis toward origin, and the z-axis extends positively from the bottom to the top of the part. 56 """ 57 58 59 # Public attributes ---------------------------------------------------------------------------- 60 61 name: str 62 """Unique, identifying name of the `SymPart` instance.""" 63 64 geometry: Geometry 65 """Part-specific geometry parameters.""" 66 67 attachment_points: List[Coordinate] 68 """List of local points on the SymPart that can attach to other SymParts.""" 69 70 attachments: Dict[str, str] 71 """Dictionary of SymParts and attachment points that are rigidly attached to this SymPart.""" 72 73 connection_ports: List[Coordinate] 74 """List of local points on the SymPart that can connect flexibly to other SymParts.""" 75 76 connections: Dict[str, str] 77 """Dictionary of SymParts and connection ports that are flexibly connected to this SymPart.""" 78 79 static_origin: Union[Coordinate, None] 80 """Local point on the unoriented SymPart that is used for static placement and rotation.""" 81 82 static_placement: Union[Coordinate, None] 83 """Global static placement of the `static_origin` of the SymPart.""" 84 85 orientation: Rotation 86 """Global orientation of the SymPart (no rotation by default).""" 87 88 material_density: float 89 """Uniform material density in `kg/m^3` to be used in mass property calculations.""" 90 91 current_states: List[str] 92 """List of geometric states for which the SymPart is currently configured.""" 93 94 is_exposed: bool 95 """Whether the SymPart is environmentally exposed versus contained in another element.""" 96 97 98 # Constructor ---------------------------------------------------------------------------------- 99 100 def __init__(self, identifier: str, 101 cad_representation: Union[str, Callable], 102 properties_model: Union[str, NeuralNet, None], 103 material_density: float) -> None: 104 """Initializes an instance of a `SymPart`. 105 106 The underlying `cad_representation` may either be a predefined FreeCAD model or a reference 107 to a method that is able to create such a model. The `material_density` indicates the 108 uniform material density in `kg/m^3` that should be used in mass property calculations for 109 the given SymPart. 110 111 Parameters 112 ---------- 113 identifier : `str` 114 Unique, identifying name for the SymPart. 115 cad_representation : `Union[str, Callable]` 116 Either the path to a representative CAD model for the given SymPart or a callable method 117 that can create such a model. 118 properties_model : `Union[str, NeuralNet, None]` 119 Path to or instance of a neural network that may be evaluated to obtain the underlying 120 geometric properties for the given SymPart. 121 material_density: `float` 122 Uniform material density in `kg/m^3` to be used in mass property calculations. 123 """ 124 super().__init__() 125 self.name = identifier 126 self.geometry = Geometry(identifier) 127 self.attachment_points = [] 128 self.attachments = {} 129 self.connection_ports = [] 130 self.connections = {} 131 self.static_origin = None 132 self.static_placement = None 133 self.orientation = Rotation(identifier + '_orientation') 134 self.material_density = material_density 135 self.current_states = [] 136 self.is_exposed = True 137 self.__cad__ = ModeledCad(cad_representation) if isinstance(cad_representation, str) else \ 138 ScriptedCad(cad_representation) 139 self.__neural_net__ = NeuralNet(identifier, properties_model) \ 140 if (properties_model and isinstance(properties_model, str)) else \ 141 properties_model 142 143 144 # Built-in method implementations -------------------------------------------------------------- 145 146 def __repr__(self) -> str: 147 return str(type(self)).split('.')[-1].split('\'')[0] + ' (' + self.name + '): Geometry: [' \ 148 + str(self.geometry) + '], Placement: [' + str(self.static_placement) \ 149 + '], Orientation: [' + str(self.orientation) + '], Density: ' \ 150 + str(self.material_density) + ' kg/m^3, Exposed: ' + str(self.is_exposed) 151 152 def __eq__(self, other: SymPart) -> bool: 153 for key, val in self.__dict__.items(): 154 if key != 'name' and (key not in other.__dict__ or val != getattr(other, key)): 155 return False 156 return type(self) == type(other) 157 158 def __copy__(self) -> SymPartSub: 159 copy = self.__class__.__new__(self.__class__) 160 copy.__dict__.update(self.__dict__) 161 return copy 162 163 def __deepcopy__(self, memo) -> SymPartSub: 164 copy = self.__class__.__new__(self.__class__) 165 memo[id(self)] = copy 166 for key, val in self.__dict__.items(): 167 setattr(copy, key, deepcopy(val, memo)) 168 return copy 169 170 def __imul__(self: SymPartSub, value: float) -> SymPartSub: 171 self.geometry *= value 172 return self 173 174 def __itruediv__(self: SymPartSub, value: float) -> SymPartSub: 175 self.geometry /= value 176 return self 177 178 179 # Public methods ------------------------------------------------------------------------------- 180 181 def clone(self: SymPartSub) -> SymPartSub: 182 """Returns an exact clone of this `SymPart` instance.""" 183 return deepcopy(self) 184 185 186 def set_placement(self: SymPartSub, *, placement: Tuple[Union[float, Expr, None], 187 Union[float, Expr, None], 188 Union[float, Expr, None]], 189 local_origin: Tuple[float, float, float]) -> SymPartSub: 190 """Sets the global placement of the `local_origin` of this SymPart. 191 192 Parameters 193 ---------- 194 placement : `Tuple` of `Union[float, sympy.Expr, None]` 195 Global XYZ placement of the `local_origin` of the SymPart in meters. If `None` is 196 specified for any given axis, placement on that axis will be treated as a symbol. 197 local_origin : `Tuple[float, float, float]` 198 Local XYZ point on the unoriented SymPart to be used for static placement and rotation. 199 Each coordinate of the origin point should fall in the range `[0.0, 1.0]` and be relative 200 to the *x-axis* length, *y-axis* width, and *z-axis* height of the SymPart with its 201 origin at the front, left, center of the part. 202 203 Returns 204 ------- 205 self : `SymPart` 206 The current SymPart being manipulated. 207 """ 208 if self.static_placement is None: 209 self.static_placement = Coordinate(self.name + '_placement') 210 self.static_placement.set(x=placement[0], y=placement[1], z=placement[2]) 211 if self.static_origin is None: 212 self.static_origin = Coordinate(self.name + '_origin') 213 self.static_origin.set(x=local_origin[0], y=local_origin[1], z=local_origin[2]) 214 return self 215 216 217 def set_orientation(self: SymPartSub, *, roll_deg: Union[float, Expr, None], 218 pitch_deg: Union[float, Expr, None], 219 yaw_deg: Union[float, Expr, None]) -> SymPartSub: 220 """Sets the global orientation of the SymPart when rotated about its `z-`, `y-`, then 221 `x-axis` (`yaw`, `pitch`, then `roll`) using a right-hand coordinate system. 222 223 A positive `roll_deg` will tilt the part to the left from the point of view of a location 224 inside the part. A positive `pitch_deg` will rotate the nose of the part downward, and a 225 positive `yaw_deg` will rotate the nose of the part toward the left from the point of view 226 of a location inside the part. 227 228 Parameters 229 ---------- 230 roll_deg : `Union[float, sympy.Expr, None]` 231 Desired intrinsic roll angle in degrees. If `None` is specified, the angle will be 232 treated as a symbol. 233 pitch_deg : `Union[float, sympy.Expr, None]` 234 Desired intrinsic pitch angle in degrees. If `None` is specified, the angle will be 235 treated as a symbol. 236 yaw_deg : `Union[float, sympy.Expr, None]` 237 Desired intrinsic yaw angle in degress. If `None` is specified, the angle will be 238 treated as a symbol. 239 240 Returns 241 ------- 242 self : `SymPart` 243 The current SymPart being manipulated. 244 """ 245 self.orientation.set(roll_deg=roll_deg, pitch_deg=pitch_deg, yaw_deg=yaw_deg) 246 return self 247 248 249 def set_state(self: SymPartSub, state_names: Union[List[str], None]) -> SymPartSub: 250 """Sets the geometric configuration of the SymPart according to the indicated `state_names`. 251 252 The concrete, overriding `SymPart` class may use these `state_names` to alter its underlying 253 geometric properties. 254 255 Parameters 256 ---------- 257 state_names : `Union[List[str], None]` 258 List of geometric states for which the part should be configured. If set to `None`, 259 the part will be configured in its default state. 260 261 Returns 262 ------- 263 self : `SymPart` 264 The current SymPart being manipulated. 265 """ 266 self.current_states = [] if not state_names else \ 267 [state for state in state_names if state in self.get_valid_states()] 268 return self 269 270 271 def set_unexposed(self: SymPartSub) -> SymPartSub: 272 """Specifies that the SymPart is environmentally unexposed. 273 274 This results in the part being excluded from certain geometric property calculations such 275 as `displaced_volume`. 276 277 Returns 278 ------- 279 self : `SymPart` 280 The current SymPart being manipulated. 281 """ 282 self.is_exposed = False 283 return self 284 285 286 def set_material_density(self: SymPartSub, material_density) -> SymPartSub: 287 """Sets the material density of the SymPart. 288 289 Parameters 290 ---------- 291 material_density: `float` 292 Uniform material density in `kg/m^3` to be used in mass property calculations. 293 294 Returns 295 ------- 296 self : `SymPart` 297 The current SymPart being manipulated. 298 """ 299 self.material_density = material_density 300 return self 301 302 303 def add_attachment_point(self: SymPartSub, attachment_point_id: str, *, 304 x: Union[float, Expr], 305 y: Union[float, Expr], 306 z: Union[float, Expr]) -> SymPartSub: 307 """Adds a local attachment point to the SymPart. 308 309 Each coordinate of the attachment point should fall in the range `[0.0, 1.0]` and be 310 relative to the *x-axis* length, *y-axis* width, and *z-axis* height of the SymPart with 311 its origin at the front, left, center of the part. 312 313 Parameters 314 ---------- 315 attachment_point_id : `str` 316 Unique identifier for the new attachment point. 317 x : `Union[float, sympy.Expr]` 318 Local x-axis placement of the attachment point on the SymPart relative to its length. 319 y : `Union[float, sympy.Expr]` 320 Local y-axis placement of the attachment point on the SymPart relative to its width. 321 z : `Union[float, sympy.Expr]` 322 Local z-axis placement of the attachment point on the SymPart relative to its height. 323 324 Returns 325 ------- 326 self : `SymPart` 327 The current SymPart being manipulated. 328 """ 329 if attachment_point_id in [point.name for point in self.attachment_points]: 330 raise ValueError('An attachment point with the ID "{}" already exists' 331 .format(attachment_point_id)) 332 self.attachment_points.append(Coordinate(attachment_point_id, x=x, y=y, z=z)) 333 return self 334 335 336 def add_connection_port(self: SymPartSub, connection_port_id: str, *, 337 x: Union[float, Expr], 338 y: Union[float, Expr], 339 z: Union[float, Expr]) -> SymPartSub: 340 """Adds a local connection port to the SymPart. 341 342 Each coordinate of the connection port should fall in the range `[0.0, 1.0]` and be 343 relative to the *x-axis* length, *y-axis* width, and *z-axis* height of the SymPart with 344 its origin at the front, left, center of the part. 345 346 Parameters 347 ---------- 348 connection_port_id : `str` 349 Unique identifier for the new connection port. 350 x : `Union[float, sympy.Expr]` 351 Local x-axis placement of the connection port on the SymPart relative to its length. 352 y : `Union[float, sympy.Expr]` 353 Local y-axis placement of the connection port on the SymPart relative to its width. 354 z : `Union[float, sympy.Expr]` 355 Local z-axis placement of the connection port on the SymPart relative to its height. 356 357 Returns 358 ------- 359 self : `SymPart` 360 The current SymPart being manipulated. 361 """ 362 if connection_port_id in [port.name for port in self.connection_ports]: 363 raise ValueError('A connection port with the ID "{}" already exists' 364 .format(connection_port_id)) 365 self.connection_ports.append(Coordinate(connection_port_id, x=x, y=y, z=z)) 366 return self 367 368 369 def attach(self: SymPartSub, local_attachment_id: str, 370 remote_part: SymPart, 371 remote_attachment_id: str) -> SymPartSub: 372 """Creates a rigid attachment between a local and remote attachment point. 373 374 Parameters 375 ---------- 376 local_attachment_id : `str` 377 Identifier of the local attachment point to which to attach. 378 remote_part : `SymPart` 379 The remote SymPart to which to make an attachment. 380 remote_attachment_id : `str` 381 Identifier of the remote attachment point to which to attach. 382 383 Returns 384 ------- 385 self : `SymPart` 386 The current SymPart being manipulated. 387 """ 388 389 # Ensure that the requested attachment is valid 390 if self.name == remote_part.name: 391 raise ValueError('The local and attached parts cannot both have the same name "{}"' 392 .format(self.name)) 393 if local_attachment_id not in [point.name for point in self.attachment_points]: 394 raise ValueError('The local attachment point identifier "{}" does not exist' 395 .format(local_attachment_id)) 396 if remote_attachment_id not in [point.name for point in remote_part.attachment_points]: 397 raise ValueError('The remote attachment point identifier "{}" does not exist' 398 .format(remote_attachment_id)) 399 if local_attachment_id in self.attachments: 400 raise ValueError('The local attachment point "{}" is already being used' 401 .format(local_attachment_id)) 402 if remote_attachment_id in remote_part.attachments: 403 raise ValueError('The remote attachment point "{}" is already being used' 404 .format(remote_attachment_id)) 405 406 # Make the rigid attachment in both directions 407 self.attachments[local_attachment_id] = remote_part.name + '#' + remote_attachment_id 408 remote_part.attachments[remote_attachment_id] = self.name + '#' + local_attachment_id 409 return self 410 411 412 def connect(self: SymPartSub, local_connection_id: str, 413 remote_part: SymPart, 414 remote_connection_id: str) -> SymPartSub: 415 """Creates a non-rigid connection between a local and remote connection port. 416 417 Parameters 418 ---------- 419 local_connection_id : `str` 420 Identifier of the local connection port to which to connect. 421 remote_part : `SymPart` 422 The remote SymPart to which to make a connection. 423 remote_connection_id : `str` 424 Identifier of the remote connection port to which to connect. 425 426 Returns 427 ------- 428 self : `SymPart` 429 The current SymPart being manipulated. 430 """ 431 432 # Ensure that the requested connection is valid 433 if self.name == remote_part.name: 434 raise ValueError('The local and connected parts cannot both have the same name "{}"' 435 .format(self.name)) 436 if local_connection_id not in [port.name for port in self.connection_ports]: 437 raise ValueError('The local connection port identifier "{}" does not exist' 438 .format(local_connection_id)) 439 if remote_connection_id not in [port.name for port in remote_part.connection_ports]: 440 raise ValueError('The remote connection port identifier "{}" does not exist' 441 .format(remote_connection_id)) 442 if local_connection_id in self.connections: 443 raise ValueError('The local connection port "{}" is already being used' 444 .format(local_connection_id)) 445 if remote_connection_id in remote_part.connections: 446 raise ValueError('The remote connection port "{}" is already being used' 447 .format(remote_connection_id)) 448 449 # Make the flexible connection in both directions 450 self.connections[local_connection_id] = remote_part.name + '#' + remote_connection_id 451 remote_part.connections[remote_connection_id] = self.name + '#' + local_connection_id 452 return self 453 454 455 def get_cad_physical_properties(self, 456 normalize_origin: Optional[bool] = False) -> Dict[str, float]: 457 """Retrieves the set of physical properties of the SymPart as reported by the underlying 458 CAD model. 459 460 Parameters 461 ---------- 462 normalize_origin : `bool`, optional, default=False 463 Return physical properties with respect to the front, left, bottom corner of the 464 underlying CAD model. 465 466 Returns 467 ------- 468 `Dict[str, float]` 469 A list of physical properties as calculated from the underlying CAD model. 470 """ 471 placement_center = self.static_origin.as_tuple() if \ 472 self.static_origin is not None else \ 473 (0.0, 0.0, 0.0) 474 return self.__cad__.get_physical_properties(self.geometry.__dict__, 475 placement_center, 476 self.orientation.as_tuple(), 477 self.material_density, 478 normalize_origin) 479 480 481 def export(self, save_path: str, export_type: Literal['freecad', 'step', 'stl']) -> None: 482 """Exports the SymPart to an external CAD representation. 483 484 Supported CAD model formats currently include FreeCAD, STEP, and STL. 485 486 Parameters 487 ---------- 488 save_path : `str` 489 Output file path at which to store the the generated CAD model. 490 export_type : {'freecad', 'step', 'stl'} 491 Format of the CAD model to export. 492 """ 493 placement_center = self.static_origin.as_tuple() if \ 494 self.static_origin is not None else \ 495 (0.0, 0.0, 0.0) 496 self.__cad__.export_model(save_path, 497 export_type, 498 self.geometry.__dict__, 499 placement_center, 500 self.orientation.as_tuple()) 501 502 503 # Abstract methods to be overridden ------------------------------------------------------------ 504 505 @abc.abstractmethod 506 def set_geometry(self: SymPartSub, **kwargs) -> SymPartSub: 507 """Abstract method that must be overridden by a concrete `SymPart` class to allow setting 508 its physical geometry. 509 510 Parameters 511 ---------- 512 **kwargs : `Dict` 513 Set of named parameters that define the geometry of a SymPart. 514 515 Raises 516 ------- 517 NotImplementedError 518 If the implementing `SymPart` class does not override this method. 519 """ 520 raise NotImplementedError 521 522 @abc.abstractmethod 523 def get_geometric_parameter_bounds(self: SymPartSub, parameter: str) -> Tuple[float, float]: 524 """Abstract method that must be overridden by a concrete `SymPart` class to return the 525 minimum and maximum expected bounds for a given geometric parameter. 526 527 Parameters 528 ---------- 529 parameter : `str` 530 Name of the geometric parameter for which to return the minimum and maximum bounds. 531 532 Returns 533 ------- 534 `Tuple[float, float]` 535 Minimum and maximum bounds for the specified geometric `parameter`.""" 536 raise NotImplementedError 537 538 def get_valid_states(self: SymPartSub) -> List[str]: 539 """Method that may be overridden by a concrete `SymPart` class to indicate the list of 540 geometric state names recognized by the part. 541 542 If this method is not overridden, it will return an empty list, indicating that the part 543 only has one valid geometric state. 544 """ 545 return [] 546 547 548 # Abstract properties that must be overridden -------------------------------------------------- 549 550 @property 551 def mass(self) -> Union[float, Expr]: 552 """Mass (in `kg`) of the SymPart (read-only).""" 553 return self.material_volume * self.material_density 554 555 @property 556 @abc.abstractmethod 557 def material_volume(self) -> Union[float, Expr]: 558 """Material volume (in `m^3`) of the SymPart (read-only).""" 559 raise NotImplementedError 560 561 @property 562 @abc.abstractmethod 563 def displaced_volume(self) -> Union[float, Expr]: 564 """Displaced volume (in `m^3`) of the SymPart (read-only).""" 565 raise NotImplementedError 566 567 @property 568 @abc.abstractmethod 569 def surface_area(self) -> Union[float, Expr]: 570 """Surface/wetted area (in `m^2`) of the SymPart (read-only).""" 571 raise NotImplementedError 572 573 @property 574 @abc.abstractmethod 575 def unoriented_center_of_gravity(self) -> Tuple[Union[float, Expr], 576 Union[float, Expr], 577 Union[float, Expr]]: 578 """Center of gravity (in `m`) of the **unoriented** SymPart (read-only).""" 579 raise NotImplementedError 580 581 @property 582 def oriented_center_of_gravity(self) -> Tuple[Union[float, Expr], 583 Union[float, Expr], 584 Union[float, Expr]]: 585 """Center of gravity (in `m`) of the **oriented** SymPart (read-only).""" 586 origin = self.static_origin.clone() \ 587 if self.static_origin is not None else \ 588 Coordinate(self.name + '_origin') 589 center_of_gravity = self.unoriented_center_of_gravity 590 center_of_gravity = [center_of_gravity[0] - (origin.x * self.unoriented_length), 591 center_of_gravity[1] - (origin.y * self.unoriented_width), 592 center_of_gravity[2] - (origin.z * self.unoriented_height)] 593 return self.orientation.rotate_point((0.0, 0.0, 0.0), center_of_gravity) 594 595 @property 596 @abc.abstractmethod 597 def unoriented_center_of_buoyancy(self) -> Tuple[Union[float, Expr], 598 Union[float, Expr], 599 Union[float, Expr]]: 600 """Center of buoyancy (in `m`) of the **unoriented** SymPart (read-only).""" 601 raise NotImplementedError 602 603 @property 604 def oriented_center_of_buoyancy(self) -> Tuple[Union[float, Expr], 605 Union[float, Expr], 606 Union[float, Expr]]: 607 """Center of buoyancy (in `m`) of the **oriented** SymPart (read-only).""" 608 origin = self.static_origin.clone() \ 609 if self.static_origin is not None else \ 610 Coordinate(self.name + '_origin') 611 center_of_buoyancy = self.unoriented_center_of_buoyancy 612 center_of_buoyancy = [center_of_buoyancy[0] - (origin.x * self.unoriented_length), 613 center_of_buoyancy[1] - (origin.y * self.unoriented_width), 614 center_of_buoyancy[2] - (origin.z * self.unoriented_height)] 615 return self.orientation.rotate_point((0.0, 0.0, 0.0), center_of_buoyancy) 616 617 @property 618 @abc.abstractmethod 619 def unoriented_length(self) -> Union[float, Expr]: 620 """X-axis length (in `m`) of the bounding box of the **unoriented** SymPart (read-only).""" 621 raise NotImplementedError 622 623 @property 624 @abc.abstractmethod 625 def unoriented_width(self) -> Union[float, Expr]: 626 """Y-axis width (in `m`) of the bounding box of the **unoriented** SymPart (read-only).""" 627 raise NotImplementedError 628 629 @property 630 @abc.abstractmethod 631 def unoriented_height(self) -> Union[float, Expr]: 632 """Z-axis height (in `m`) of the bounding box of the **unoriented** SymPart (read-only).""" 633 raise NotImplementedError 634 635 @property 636 def oriented_length(self) -> Union[float, Expr]: 637 """X-axis length (in `m`) of the bounding box of the **oriented** SymPart (read-only).""" 638 R = self.orientation.get_rotation_matrix_row(0) 639 original_extents = [self.unoriented_length, self.unoriented_width, self.unoriented_height] 640 return sum([abs(R[i] * original_extents[i]) for i in range(3)]) 641 642 @property 643 def oriented_width(self) -> Union[float, Expr]: 644 """Y-axis length (in `m`) of the bounding box of the **oriented** SymPart (read-only).""" 645 R = self.orientation.get_rotation_matrix_row(1) 646 original_extents = [self.unoriented_length, self.unoriented_width, self.unoriented_height] 647 return sum([abs(R[i] * original_extents[i]) for i in range(3)]) 648 649 @property 650 def oriented_height(self) -> Union[float, Expr]: 651 """Z-axis length (in `m`) of the bounding box of the **oriented** SymPart (read-only).""" 652 R = self.orientation.get_rotation_matrix_row(2) 653 original_extents = [self.unoriented_length, self.unoriented_width, self.unoriented_height] 654 return sum([abs(R[i] * original_extents[i]) for i in range(3)])
32class SymPart(metaclass=abc.ABCMeta): 33 """Symbolic part base class from which all SymParts inherit. 34 35 Defines the interface to a set of abstract properties that all SymParts possess, including 36 mass, material volume, displaced volume, surface area, centroid, centers of gravity and 37 buouyancy, length, width, and height, among others. These properties may be concrete or 38 symbolic, and they represent the external interface that is expected to be used when 39 accessing the physical properties of a given SymPart. 40 41 When creating a new SymPart, its geometry and global placement will be treated as symbolic 42 parameters, and its global orientation will be assumed to be 0 such that the SymPart does not 43 rotate in space. These assumptions can be overridden using the various `set_*` methods of the 44 SymPart object. Attachment points can be defined to indicate areas on a SymPart that are able 45 to rigidly attach to other SymParts, or connection ports can be defined to indicate areas that 46 are able to flexibly and/or non-mechanically connect to other SymParts. 47 48 By default, a SymPart is assumed to be environmentally exposed and thus contribute to such 49 geometric properties as the displaced volume. If a SymPart is not exposed, for example a dry 50 component inside of a pressurized container, this should be specified by calling the 51 `set_unexposed()` method on the SymPart object. 52 53 The local coordinate space of a SymPart is defined with its origin at the front, center, 54 bottom of the part, where the x-axis extends positively from its front to its rear, the y-axis 55 extends positively from the xz-plane to the right of the part when looking from the positive 56 x-axis toward origin, and the z-axis extends positively from the bottom to the top of the part. 57 """ 58 59 60 # Public attributes ---------------------------------------------------------------------------- 61 62 name: str 63 """Unique, identifying name of the `SymPart` instance.""" 64 65 geometry: Geometry 66 """Part-specific geometry parameters.""" 67 68 attachment_points: List[Coordinate] 69 """List of local points on the SymPart that can attach to other SymParts.""" 70 71 attachments: Dict[str, str] 72 """Dictionary of SymParts and attachment points that are rigidly attached to this SymPart.""" 73 74 connection_ports: List[Coordinate] 75 """List of local points on the SymPart that can connect flexibly to other SymParts.""" 76 77 connections: Dict[str, str] 78 """Dictionary of SymParts and connection ports that are flexibly connected to this SymPart.""" 79 80 static_origin: Union[Coordinate, None] 81 """Local point on the unoriented SymPart that is used for static placement and rotation.""" 82 83 static_placement: Union[Coordinate, None] 84 """Global static placement of the `static_origin` of the SymPart.""" 85 86 orientation: Rotation 87 """Global orientation of the SymPart (no rotation by default).""" 88 89 material_density: float 90 """Uniform material density in `kg/m^3` to be used in mass property calculations.""" 91 92 current_states: List[str] 93 """List of geometric states for which the SymPart is currently configured.""" 94 95 is_exposed: bool 96 """Whether the SymPart is environmentally exposed versus contained in another element.""" 97 98 99 # Constructor ---------------------------------------------------------------------------------- 100 101 def __init__(self, identifier: str, 102 cad_representation: Union[str, Callable], 103 properties_model: Union[str, NeuralNet, None], 104 material_density: float) -> None: 105 """Initializes an instance of a `SymPart`. 106 107 The underlying `cad_representation` may either be a predefined FreeCAD model or a reference 108 to a method that is able to create such a model. The `material_density` indicates the 109 uniform material density in `kg/m^3` that should be used in mass property calculations for 110 the given SymPart. 111 112 Parameters 113 ---------- 114 identifier : `str` 115 Unique, identifying name for the SymPart. 116 cad_representation : `Union[str, Callable]` 117 Either the path to a representative CAD model for the given SymPart or a callable method 118 that can create such a model. 119 properties_model : `Union[str, NeuralNet, None]` 120 Path to or instance of a neural network that may be evaluated to obtain the underlying 121 geometric properties for the given SymPart. 122 material_density: `float` 123 Uniform material density in `kg/m^3` to be used in mass property calculations. 124 """ 125 super().__init__() 126 self.name = identifier 127 self.geometry = Geometry(identifier) 128 self.attachment_points = [] 129 self.attachments = {} 130 self.connection_ports = [] 131 self.connections = {} 132 self.static_origin = None 133 self.static_placement = None 134 self.orientation = Rotation(identifier + '_orientation') 135 self.material_density = material_density 136 self.current_states = [] 137 self.is_exposed = True 138 self.__cad__ = ModeledCad(cad_representation) if isinstance(cad_representation, str) else \ 139 ScriptedCad(cad_representation) 140 self.__neural_net__ = NeuralNet(identifier, properties_model) \ 141 if (properties_model and isinstance(properties_model, str)) else \ 142 properties_model 143 144 145 # Built-in method implementations -------------------------------------------------------------- 146 147 def __repr__(self) -> str: 148 return str(type(self)).split('.')[-1].split('\'')[0] + ' (' + self.name + '): Geometry: [' \ 149 + str(self.geometry) + '], Placement: [' + str(self.static_placement) \ 150 + '], Orientation: [' + str(self.orientation) + '], Density: ' \ 151 + str(self.material_density) + ' kg/m^3, Exposed: ' + str(self.is_exposed) 152 153 def __eq__(self, other: SymPart) -> bool: 154 for key, val in self.__dict__.items(): 155 if key != 'name' and (key not in other.__dict__ or val != getattr(other, key)): 156 return False 157 return type(self) == type(other) 158 159 def __copy__(self) -> SymPartSub: 160 copy = self.__class__.__new__(self.__class__) 161 copy.__dict__.update(self.__dict__) 162 return copy 163 164 def __deepcopy__(self, memo) -> SymPartSub: 165 copy = self.__class__.__new__(self.__class__) 166 memo[id(self)] = copy 167 for key, val in self.__dict__.items(): 168 setattr(copy, key, deepcopy(val, memo)) 169 return copy 170 171 def __imul__(self: SymPartSub, value: float) -> SymPartSub: 172 self.geometry *= value 173 return self 174 175 def __itruediv__(self: SymPartSub, value: float) -> SymPartSub: 176 self.geometry /= value 177 return self 178 179 180 # Public methods ------------------------------------------------------------------------------- 181 182 def clone(self: SymPartSub) -> SymPartSub: 183 """Returns an exact clone of this `SymPart` instance.""" 184 return deepcopy(self) 185 186 187 def set_placement(self: SymPartSub, *, placement: Tuple[Union[float, Expr, None], 188 Union[float, Expr, None], 189 Union[float, Expr, None]], 190 local_origin: Tuple[float, float, float]) -> SymPartSub: 191 """Sets the global placement of the `local_origin` of this SymPart. 192 193 Parameters 194 ---------- 195 placement : `Tuple` of `Union[float, sympy.Expr, None]` 196 Global XYZ placement of the `local_origin` of the SymPart in meters. If `None` is 197 specified for any given axis, placement on that axis will be treated as a symbol. 198 local_origin : `Tuple[float, float, float]` 199 Local XYZ point on the unoriented SymPart to be used for static placement and rotation. 200 Each coordinate of the origin point should fall in the range `[0.0, 1.0]` and be relative 201 to the *x-axis* length, *y-axis* width, and *z-axis* height of the SymPart with its 202 origin at the front, left, center of the part. 203 204 Returns 205 ------- 206 self : `SymPart` 207 The current SymPart being manipulated. 208 """ 209 if self.static_placement is None: 210 self.static_placement = Coordinate(self.name + '_placement') 211 self.static_placement.set(x=placement[0], y=placement[1], z=placement[2]) 212 if self.static_origin is None: 213 self.static_origin = Coordinate(self.name + '_origin') 214 self.static_origin.set(x=local_origin[0], y=local_origin[1], z=local_origin[2]) 215 return self 216 217 218 def set_orientation(self: SymPartSub, *, roll_deg: Union[float, Expr, None], 219 pitch_deg: Union[float, Expr, None], 220 yaw_deg: Union[float, Expr, None]) -> SymPartSub: 221 """Sets the global orientation of the SymPart when rotated about its `z-`, `y-`, then 222 `x-axis` (`yaw`, `pitch`, then `roll`) using a right-hand coordinate system. 223 224 A positive `roll_deg` will tilt the part to the left from the point of view of a location 225 inside the part. A positive `pitch_deg` will rotate the nose of the part downward, and a 226 positive `yaw_deg` will rotate the nose of the part toward the left from the point of view 227 of a location inside the part. 228 229 Parameters 230 ---------- 231 roll_deg : `Union[float, sympy.Expr, None]` 232 Desired intrinsic roll angle in degrees. If `None` is specified, the angle will be 233 treated as a symbol. 234 pitch_deg : `Union[float, sympy.Expr, None]` 235 Desired intrinsic pitch angle in degrees. If `None` is specified, the angle will be 236 treated as a symbol. 237 yaw_deg : `Union[float, sympy.Expr, None]` 238 Desired intrinsic yaw angle in degress. If `None` is specified, the angle will be 239 treated as a symbol. 240 241 Returns 242 ------- 243 self : `SymPart` 244 The current SymPart being manipulated. 245 """ 246 self.orientation.set(roll_deg=roll_deg, pitch_deg=pitch_deg, yaw_deg=yaw_deg) 247 return self 248 249 250 def set_state(self: SymPartSub, state_names: Union[List[str], None]) -> SymPartSub: 251 """Sets the geometric configuration of the SymPart according to the indicated `state_names`. 252 253 The concrete, overriding `SymPart` class may use these `state_names` to alter its underlying 254 geometric properties. 255 256 Parameters 257 ---------- 258 state_names : `Union[List[str], None]` 259 List of geometric states for which the part should be configured. If set to `None`, 260 the part will be configured in its default state. 261 262 Returns 263 ------- 264 self : `SymPart` 265 The current SymPart being manipulated. 266 """ 267 self.current_states = [] if not state_names else \ 268 [state for state in state_names if state in self.get_valid_states()] 269 return self 270 271 272 def set_unexposed(self: SymPartSub) -> SymPartSub: 273 """Specifies that the SymPart is environmentally unexposed. 274 275 This results in the part being excluded from certain geometric property calculations such 276 as `displaced_volume`. 277 278 Returns 279 ------- 280 self : `SymPart` 281 The current SymPart being manipulated. 282 """ 283 self.is_exposed = False 284 return self 285 286 287 def set_material_density(self: SymPartSub, material_density) -> SymPartSub: 288 """Sets the material density of the SymPart. 289 290 Parameters 291 ---------- 292 material_density: `float` 293 Uniform material density in `kg/m^3` to be used in mass property calculations. 294 295 Returns 296 ------- 297 self : `SymPart` 298 The current SymPart being manipulated. 299 """ 300 self.material_density = material_density 301 return self 302 303 304 def add_attachment_point(self: SymPartSub, attachment_point_id: str, *, 305 x: Union[float, Expr], 306 y: Union[float, Expr], 307 z: Union[float, Expr]) -> SymPartSub: 308 """Adds a local attachment point to the SymPart. 309 310 Each coordinate of the attachment point should fall in the range `[0.0, 1.0]` and be 311 relative to the *x-axis* length, *y-axis* width, and *z-axis* height of the SymPart with 312 its origin at the front, left, center of the part. 313 314 Parameters 315 ---------- 316 attachment_point_id : `str` 317 Unique identifier for the new attachment point. 318 x : `Union[float, sympy.Expr]` 319 Local x-axis placement of the attachment point on the SymPart relative to its length. 320 y : `Union[float, sympy.Expr]` 321 Local y-axis placement of the attachment point on the SymPart relative to its width. 322 z : `Union[float, sympy.Expr]` 323 Local z-axis placement of the attachment point on the SymPart relative to its height. 324 325 Returns 326 ------- 327 self : `SymPart` 328 The current SymPart being manipulated. 329 """ 330 if attachment_point_id in [point.name for point in self.attachment_points]: 331 raise ValueError('An attachment point with the ID "{}" already exists' 332 .format(attachment_point_id)) 333 self.attachment_points.append(Coordinate(attachment_point_id, x=x, y=y, z=z)) 334 return self 335 336 337 def add_connection_port(self: SymPartSub, connection_port_id: str, *, 338 x: Union[float, Expr], 339 y: Union[float, Expr], 340 z: Union[float, Expr]) -> SymPartSub: 341 """Adds a local connection port to the SymPart. 342 343 Each coordinate of the connection port should fall in the range `[0.0, 1.0]` and be 344 relative to the *x-axis* length, *y-axis* width, and *z-axis* height of the SymPart with 345 its origin at the front, left, center of the part. 346 347 Parameters 348 ---------- 349 connection_port_id : `str` 350 Unique identifier for the new connection port. 351 x : `Union[float, sympy.Expr]` 352 Local x-axis placement of the connection port on the SymPart relative to its length. 353 y : `Union[float, sympy.Expr]` 354 Local y-axis placement of the connection port on the SymPart relative to its width. 355 z : `Union[float, sympy.Expr]` 356 Local z-axis placement of the connection port on the SymPart relative to its height. 357 358 Returns 359 ------- 360 self : `SymPart` 361 The current SymPart being manipulated. 362 """ 363 if connection_port_id in [port.name for port in self.connection_ports]: 364 raise ValueError('A connection port with the ID "{}" already exists' 365 .format(connection_port_id)) 366 self.connection_ports.append(Coordinate(connection_port_id, x=x, y=y, z=z)) 367 return self 368 369 370 def attach(self: SymPartSub, local_attachment_id: str, 371 remote_part: SymPart, 372 remote_attachment_id: str) -> SymPartSub: 373 """Creates a rigid attachment between a local and remote attachment point. 374 375 Parameters 376 ---------- 377 local_attachment_id : `str` 378 Identifier of the local attachment point to which to attach. 379 remote_part : `SymPart` 380 The remote SymPart to which to make an attachment. 381 remote_attachment_id : `str` 382 Identifier of the remote attachment point to which to attach. 383 384 Returns 385 ------- 386 self : `SymPart` 387 The current SymPart being manipulated. 388 """ 389 390 # Ensure that the requested attachment is valid 391 if self.name == remote_part.name: 392 raise ValueError('The local and attached parts cannot both have the same name "{}"' 393 .format(self.name)) 394 if local_attachment_id not in [point.name for point in self.attachment_points]: 395 raise ValueError('The local attachment point identifier "{}" does not exist' 396 .format(local_attachment_id)) 397 if remote_attachment_id not in [point.name for point in remote_part.attachment_points]: 398 raise ValueError('The remote attachment point identifier "{}" does not exist' 399 .format(remote_attachment_id)) 400 if local_attachment_id in self.attachments: 401 raise ValueError('The local attachment point "{}" is already being used' 402 .format(local_attachment_id)) 403 if remote_attachment_id in remote_part.attachments: 404 raise ValueError('The remote attachment point "{}" is already being used' 405 .format(remote_attachment_id)) 406 407 # Make the rigid attachment in both directions 408 self.attachments[local_attachment_id] = remote_part.name + '#' + remote_attachment_id 409 remote_part.attachments[remote_attachment_id] = self.name + '#' + local_attachment_id 410 return self 411 412 413 def connect(self: SymPartSub, local_connection_id: str, 414 remote_part: SymPart, 415 remote_connection_id: str) -> SymPartSub: 416 """Creates a non-rigid connection between a local and remote connection port. 417 418 Parameters 419 ---------- 420 local_connection_id : `str` 421 Identifier of the local connection port to which to connect. 422 remote_part : `SymPart` 423 The remote SymPart to which to make a connection. 424 remote_connection_id : `str` 425 Identifier of the remote connection port to which to connect. 426 427 Returns 428 ------- 429 self : `SymPart` 430 The current SymPart being manipulated. 431 """ 432 433 # Ensure that the requested connection is valid 434 if self.name == remote_part.name: 435 raise ValueError('The local and connected parts cannot both have the same name "{}"' 436 .format(self.name)) 437 if local_connection_id not in [port.name for port in self.connection_ports]: 438 raise ValueError('The local connection port identifier "{}" does not exist' 439 .format(local_connection_id)) 440 if remote_connection_id not in [port.name for port in remote_part.connection_ports]: 441 raise ValueError('The remote connection port identifier "{}" does not exist' 442 .format(remote_connection_id)) 443 if local_connection_id in self.connections: 444 raise ValueError('The local connection port "{}" is already being used' 445 .format(local_connection_id)) 446 if remote_connection_id in remote_part.connections: 447 raise ValueError('The remote connection port "{}" is already being used' 448 .format(remote_connection_id)) 449 450 # Make the flexible connection in both directions 451 self.connections[local_connection_id] = remote_part.name + '#' + remote_connection_id 452 remote_part.connections[remote_connection_id] = self.name + '#' + local_connection_id 453 return self 454 455 456 def get_cad_physical_properties(self, 457 normalize_origin: Optional[bool] = False) -> Dict[str, float]: 458 """Retrieves the set of physical properties of the SymPart as reported by the underlying 459 CAD model. 460 461 Parameters 462 ---------- 463 normalize_origin : `bool`, optional, default=False 464 Return physical properties with respect to the front, left, bottom corner of the 465 underlying CAD model. 466 467 Returns 468 ------- 469 `Dict[str, float]` 470 A list of physical properties as calculated from the underlying CAD model. 471 """ 472 placement_center = self.static_origin.as_tuple() if \ 473 self.static_origin is not None else \ 474 (0.0, 0.0, 0.0) 475 return self.__cad__.get_physical_properties(self.geometry.__dict__, 476 placement_center, 477 self.orientation.as_tuple(), 478 self.material_density, 479 normalize_origin) 480 481 482 def export(self, save_path: str, export_type: Literal['freecad', 'step', 'stl']) -> None: 483 """Exports the SymPart to an external CAD representation. 484 485 Supported CAD model formats currently include FreeCAD, STEP, and STL. 486 487 Parameters 488 ---------- 489 save_path : `str` 490 Output file path at which to store the the generated CAD model. 491 export_type : {'freecad', 'step', 'stl'} 492 Format of the CAD model to export. 493 """ 494 placement_center = self.static_origin.as_tuple() if \ 495 self.static_origin is not None else \ 496 (0.0, 0.0, 0.0) 497 self.__cad__.export_model(save_path, 498 export_type, 499 self.geometry.__dict__, 500 placement_center, 501 self.orientation.as_tuple()) 502 503 504 # Abstract methods to be overridden ------------------------------------------------------------ 505 506 @abc.abstractmethod 507 def set_geometry(self: SymPartSub, **kwargs) -> SymPartSub: 508 """Abstract method that must be overridden by a concrete `SymPart` class to allow setting 509 its physical geometry. 510 511 Parameters 512 ---------- 513 **kwargs : `Dict` 514 Set of named parameters that define the geometry of a SymPart. 515 516 Raises 517 ------- 518 NotImplementedError 519 If the implementing `SymPart` class does not override this method. 520 """ 521 raise NotImplementedError 522 523 @abc.abstractmethod 524 def get_geometric_parameter_bounds(self: SymPartSub, parameter: str) -> Tuple[float, float]: 525 """Abstract method that must be overridden by a concrete `SymPart` class to return the 526 minimum and maximum expected bounds for a given geometric parameter. 527 528 Parameters 529 ---------- 530 parameter : `str` 531 Name of the geometric parameter for which to return the minimum and maximum bounds. 532 533 Returns 534 ------- 535 `Tuple[float, float]` 536 Minimum and maximum bounds for the specified geometric `parameter`.""" 537 raise NotImplementedError 538 539 def get_valid_states(self: SymPartSub) -> List[str]: 540 """Method that may be overridden by a concrete `SymPart` class to indicate the list of 541 geometric state names recognized by the part. 542 543 If this method is not overridden, it will return an empty list, indicating that the part 544 only has one valid geometric state. 545 """ 546 return [] 547 548 549 # Abstract properties that must be overridden -------------------------------------------------- 550 551 @property 552 def mass(self) -> Union[float, Expr]: 553 """Mass (in `kg`) of the SymPart (read-only).""" 554 return self.material_volume * self.material_density 555 556 @property 557 @abc.abstractmethod 558 def material_volume(self) -> Union[float, Expr]: 559 """Material volume (in `m^3`) of the SymPart (read-only).""" 560 raise NotImplementedError 561 562 @property 563 @abc.abstractmethod 564 def displaced_volume(self) -> Union[float, Expr]: 565 """Displaced volume (in `m^3`) of the SymPart (read-only).""" 566 raise NotImplementedError 567 568 @property 569 @abc.abstractmethod 570 def surface_area(self) -> Union[float, Expr]: 571 """Surface/wetted area (in `m^2`) of the SymPart (read-only).""" 572 raise NotImplementedError 573 574 @property 575 @abc.abstractmethod 576 def unoriented_center_of_gravity(self) -> Tuple[Union[float, Expr], 577 Union[float, Expr], 578 Union[float, Expr]]: 579 """Center of gravity (in `m`) of the **unoriented** SymPart (read-only).""" 580 raise NotImplementedError 581 582 @property 583 def oriented_center_of_gravity(self) -> Tuple[Union[float, Expr], 584 Union[float, Expr], 585 Union[float, Expr]]: 586 """Center of gravity (in `m`) of the **oriented** SymPart (read-only).""" 587 origin = self.static_origin.clone() \ 588 if self.static_origin is not None else \ 589 Coordinate(self.name + '_origin') 590 center_of_gravity = self.unoriented_center_of_gravity 591 center_of_gravity = [center_of_gravity[0] - (origin.x * self.unoriented_length), 592 center_of_gravity[1] - (origin.y * self.unoriented_width), 593 center_of_gravity[2] - (origin.z * self.unoriented_height)] 594 return self.orientation.rotate_point((0.0, 0.0, 0.0), center_of_gravity) 595 596 @property 597 @abc.abstractmethod 598 def unoriented_center_of_buoyancy(self) -> Tuple[Union[float, Expr], 599 Union[float, Expr], 600 Union[float, Expr]]: 601 """Center of buoyancy (in `m`) of the **unoriented** SymPart (read-only).""" 602 raise NotImplementedError 603 604 @property 605 def oriented_center_of_buoyancy(self) -> Tuple[Union[float, Expr], 606 Union[float, Expr], 607 Union[float, Expr]]: 608 """Center of buoyancy (in `m`) of the **oriented** SymPart (read-only).""" 609 origin = self.static_origin.clone() \ 610 if self.static_origin is not None else \ 611 Coordinate(self.name + '_origin') 612 center_of_buoyancy = self.unoriented_center_of_buoyancy 613 center_of_buoyancy = [center_of_buoyancy[0] - (origin.x * self.unoriented_length), 614 center_of_buoyancy[1] - (origin.y * self.unoriented_width), 615 center_of_buoyancy[2] - (origin.z * self.unoriented_height)] 616 return self.orientation.rotate_point((0.0, 0.0, 0.0), center_of_buoyancy) 617 618 @property 619 @abc.abstractmethod 620 def unoriented_length(self) -> Union[float, Expr]: 621 """X-axis length (in `m`) of the bounding box of the **unoriented** SymPart (read-only).""" 622 raise NotImplementedError 623 624 @property 625 @abc.abstractmethod 626 def unoriented_width(self) -> Union[float, Expr]: 627 """Y-axis width (in `m`) of the bounding box of the **unoriented** SymPart (read-only).""" 628 raise NotImplementedError 629 630 @property 631 @abc.abstractmethod 632 def unoriented_height(self) -> Union[float, Expr]: 633 """Z-axis height (in `m`) of the bounding box of the **unoriented** SymPart (read-only).""" 634 raise NotImplementedError 635 636 @property 637 def oriented_length(self) -> Union[float, Expr]: 638 """X-axis length (in `m`) of the bounding box of the **oriented** SymPart (read-only).""" 639 R = self.orientation.get_rotation_matrix_row(0) 640 original_extents = [self.unoriented_length, self.unoriented_width, self.unoriented_height] 641 return sum([abs(R[i] * original_extents[i]) for i in range(3)]) 642 643 @property 644 def oriented_width(self) -> Union[float, Expr]: 645 """Y-axis length (in `m`) of the bounding box of the **oriented** SymPart (read-only).""" 646 R = self.orientation.get_rotation_matrix_row(1) 647 original_extents = [self.unoriented_length, self.unoriented_width, self.unoriented_height] 648 return sum([abs(R[i] * original_extents[i]) for i in range(3)]) 649 650 @property 651 def oriented_height(self) -> Union[float, Expr]: 652 """Z-axis length (in `m`) of the bounding box of the **oriented** SymPart (read-only).""" 653 R = self.orientation.get_rotation_matrix_row(2) 654 original_extents = [self.unoriented_length, self.unoriented_width, self.unoriented_height] 655 return sum([abs(R[i] * original_extents[i]) for i in range(3)])
Symbolic part base class from which all SymParts inherit.
Defines the interface to a set of abstract properties that all SymParts possess, including mass, material volume, displaced volume, surface area, centroid, centers of gravity and buouyancy, length, width, and height, among others. These properties may be concrete or symbolic, and they represent the external interface that is expected to be used when accessing the physical properties of a given SymPart.
When creating a new SymPart, its geometry and global placement will be treated as symbolic
parameters, and its global orientation will be assumed to be 0 such that the SymPart does not
rotate in space. These assumptions can be overridden using the various set_* methods of the
SymPart object. Attachment points can be defined to indicate areas on a SymPart that are able
to rigidly attach to other SymParts, or connection ports can be defined to indicate areas that
are able to flexibly and/or non-mechanically connect to other SymParts.
By default, a SymPart is assumed to be environmentally exposed and thus contribute to such
geometric properties as the displaced volume. If a SymPart is not exposed, for example a dry
component inside of a pressurized container, this should be specified by calling the
set_unexposed() method on the SymPart object.
The local coordinate space of a SymPart is defined with its origin at the front, center, bottom of the part, where the x-axis extends positively from its front to its rear, the y-axis extends positively from the xz-plane to the right of the part when looking from the positive x-axis toward origin, and the z-axis extends positively from the bottom to the top of the part.
101 def __init__(self, identifier: str, 102 cad_representation: Union[str, Callable], 103 properties_model: Union[str, NeuralNet, None], 104 material_density: float) -> None: 105 """Initializes an instance of a `SymPart`. 106 107 The underlying `cad_representation` may either be a predefined FreeCAD model or a reference 108 to a method that is able to create such a model. The `material_density` indicates the 109 uniform material density in `kg/m^3` that should be used in mass property calculations for 110 the given SymPart. 111 112 Parameters 113 ---------- 114 identifier : `str` 115 Unique, identifying name for the SymPart. 116 cad_representation : `Union[str, Callable]` 117 Either the path to a representative CAD model for the given SymPart or a callable method 118 that can create such a model. 119 properties_model : `Union[str, NeuralNet, None]` 120 Path to or instance of a neural network that may be evaluated to obtain the underlying 121 geometric properties for the given SymPart. 122 material_density: `float` 123 Uniform material density in `kg/m^3` to be used in mass property calculations. 124 """ 125 super().__init__() 126 self.name = identifier 127 self.geometry = Geometry(identifier) 128 self.attachment_points = [] 129 self.attachments = {} 130 self.connection_ports = [] 131 self.connections = {} 132 self.static_origin = None 133 self.static_placement = None 134 self.orientation = Rotation(identifier + '_orientation') 135 self.material_density = material_density 136 self.current_states = [] 137 self.is_exposed = True 138 self.__cad__ = ModeledCad(cad_representation) if isinstance(cad_representation, str) else \ 139 ScriptedCad(cad_representation) 140 self.__neural_net__ = NeuralNet(identifier, properties_model) \ 141 if (properties_model and isinstance(properties_model, str)) else \ 142 properties_model
Initializes an instance of a SymPart.
The underlying cad_representation may either be a predefined FreeCAD model or a reference
to a method that is able to create such a model. The material_density indicates the
uniform material density in kg/m^3 that should be used in mass property calculations for
the given SymPart.
Parameters
- identifier (
str): Unique, identifying name for the SymPart. - cad_representation (
Union[str, Callable]): Either the path to a representative CAD model for the given SymPart or a callable method that can create such a model. - properties_model (
Union[str, NeuralNet, None]): Path to or instance of a neural network that may be evaluated to obtain the underlying geometric properties for the given SymPart. - material_density (
float): Uniform material density inkg/m^3to be used in mass property calculations.
List of local points on the SymPart that can attach to other SymParts.
Dictionary of SymParts and attachment points that are rigidly attached to this SymPart.
List of local points on the SymPart that can connect flexibly to other SymParts.
Dictionary of SymParts and connection ports that are flexibly connected to this SymPart.
Local point on the unoriented SymPart that is used for static placement and rotation.
Global static placement of the static_origin of the SymPart.
Global orientation of the SymPart (no rotation by default).
Uniform material density in kg/m^3 to be used in mass property calculations.
Whether the SymPart is environmentally exposed versus contained in another element.
182 def clone(self: SymPartSub) -> SymPartSub: 183 """Returns an exact clone of this `SymPart` instance.""" 184 return deepcopy(self)
Returns an exact clone of this SymPart instance.
187 def set_placement(self: SymPartSub, *, placement: Tuple[Union[float, Expr, None], 188 Union[float, Expr, None], 189 Union[float, Expr, None]], 190 local_origin: Tuple[float, float, float]) -> SymPartSub: 191 """Sets the global placement of the `local_origin` of this SymPart. 192 193 Parameters 194 ---------- 195 placement : `Tuple` of `Union[float, sympy.Expr, None]` 196 Global XYZ placement of the `local_origin` of the SymPart in meters. If `None` is 197 specified for any given axis, placement on that axis will be treated as a symbol. 198 local_origin : `Tuple[float, float, float]` 199 Local XYZ point on the unoriented SymPart to be used for static placement and rotation. 200 Each coordinate of the origin point should fall in the range `[0.0, 1.0]` and be relative 201 to the *x-axis* length, *y-axis* width, and *z-axis* height of the SymPart with its 202 origin at the front, left, center of the part. 203 204 Returns 205 ------- 206 self : `SymPart` 207 The current SymPart being manipulated. 208 """ 209 if self.static_placement is None: 210 self.static_placement = Coordinate(self.name + '_placement') 211 self.static_placement.set(x=placement[0], y=placement[1], z=placement[2]) 212 if self.static_origin is None: 213 self.static_origin = Coordinate(self.name + '_origin') 214 self.static_origin.set(x=local_origin[0], y=local_origin[1], z=local_origin[2]) 215 return self
Sets the global placement of the local_origin of this SymPart.
Parameters
- placement (
TupleofUnion[float, sympy.Expr, None]): Global XYZ placement of thelocal_originof the SymPart in meters. IfNoneis specified for any given axis, placement on that axis will be treated as a symbol. - local_origin (
Tuple[float, float, float]): Local XYZ point on the unoriented SymPart to be used for static placement and rotation. Each coordinate of the origin point should fall in the range[0.0, 1.0]and be relative to the x-axis length, y-axis width, and z-axis height of the SymPart with its origin at the front, left, center of the part.
Returns
- self (
SymPart): The current SymPart being manipulated.
218 def set_orientation(self: SymPartSub, *, roll_deg: Union[float, Expr, None], 219 pitch_deg: Union[float, Expr, None], 220 yaw_deg: Union[float, Expr, None]) -> SymPartSub: 221 """Sets the global orientation of the SymPart when rotated about its `z-`, `y-`, then 222 `x-axis` (`yaw`, `pitch`, then `roll`) using a right-hand coordinate system. 223 224 A positive `roll_deg` will tilt the part to the left from the point of view of a location 225 inside the part. A positive `pitch_deg` will rotate the nose of the part downward, and a 226 positive `yaw_deg` will rotate the nose of the part toward the left from the point of view 227 of a location inside the part. 228 229 Parameters 230 ---------- 231 roll_deg : `Union[float, sympy.Expr, None]` 232 Desired intrinsic roll angle in degrees. If `None` is specified, the angle will be 233 treated as a symbol. 234 pitch_deg : `Union[float, sympy.Expr, None]` 235 Desired intrinsic pitch angle in degrees. If `None` is specified, the angle will be 236 treated as a symbol. 237 yaw_deg : `Union[float, sympy.Expr, None]` 238 Desired intrinsic yaw angle in degress. If `None` is specified, the angle will be 239 treated as a symbol. 240 241 Returns 242 ------- 243 self : `SymPart` 244 The current SymPart being manipulated. 245 """ 246 self.orientation.set(roll_deg=roll_deg, pitch_deg=pitch_deg, yaw_deg=yaw_deg) 247 return self
Sets the global orientation of the SymPart when rotated about its z-, y-, then
x-axis (yaw, pitch, then roll) using a right-hand coordinate system.
A positive roll_deg will tilt the part to the left from the point of view of a location
inside the part. A positive pitch_deg will rotate the nose of the part downward, and a
positive yaw_deg will rotate the nose of the part toward the left from the point of view
of a location inside the part.
Parameters
- roll_deg (
Union[float, sympy.Expr, None]): Desired intrinsic roll angle in degrees. IfNoneis specified, the angle will be treated as a symbol. - pitch_deg (
Union[float, sympy.Expr, None]): Desired intrinsic pitch angle in degrees. IfNoneis specified, the angle will be treated as a symbol. - yaw_deg (
Union[float, sympy.Expr, None]): Desired intrinsic yaw angle in degress. IfNoneis specified, the angle will be treated as a symbol.
Returns
- self (
SymPart): The current SymPart being manipulated.
250 def set_state(self: SymPartSub, state_names: Union[List[str], None]) -> SymPartSub: 251 """Sets the geometric configuration of the SymPart according to the indicated `state_names`. 252 253 The concrete, overriding `SymPart` class may use these `state_names` to alter its underlying 254 geometric properties. 255 256 Parameters 257 ---------- 258 state_names : `Union[List[str], None]` 259 List of geometric states for which the part should be configured. If set to `None`, 260 the part will be configured in its default state. 261 262 Returns 263 ------- 264 self : `SymPart` 265 The current SymPart being manipulated. 266 """ 267 self.current_states = [] if not state_names else \ 268 [state for state in state_names if state in self.get_valid_states()] 269 return self
Sets the geometric configuration of the SymPart according to the indicated state_names.
The concrete, overriding SymPart class may use these state_names to alter its underlying
geometric properties.
Parameters
- state_names (
Union[List[str], None]): List of geometric states for which the part should be configured. If set toNone, the part will be configured in its default state.
Returns
- self (
SymPart): The current SymPart being manipulated.
272 def set_unexposed(self: SymPartSub) -> SymPartSub: 273 """Specifies that the SymPart is environmentally unexposed. 274 275 This results in the part being excluded from certain geometric property calculations such 276 as `displaced_volume`. 277 278 Returns 279 ------- 280 self : `SymPart` 281 The current SymPart being manipulated. 282 """ 283 self.is_exposed = False 284 return self
Specifies that the SymPart is environmentally unexposed.
This results in the part being excluded from certain geometric property calculations such
as displaced_volume.
Returns
- self (
SymPart): The current SymPart being manipulated.
287 def set_material_density(self: SymPartSub, material_density) -> SymPartSub: 288 """Sets the material density of the SymPart. 289 290 Parameters 291 ---------- 292 material_density: `float` 293 Uniform material density in `kg/m^3` to be used in mass property calculations. 294 295 Returns 296 ------- 297 self : `SymPart` 298 The current SymPart being manipulated. 299 """ 300 self.material_density = material_density 301 return self
Sets the material density of the SymPart.
Parameters
- material_density (
float): Uniform material density inkg/m^3to be used in mass property calculations.
Returns
- self (
SymPart): The current SymPart being manipulated.
304 def add_attachment_point(self: SymPartSub, attachment_point_id: str, *, 305 x: Union[float, Expr], 306 y: Union[float, Expr], 307 z: Union[float, Expr]) -> SymPartSub: 308 """Adds a local attachment point to the SymPart. 309 310 Each coordinate of the attachment point should fall in the range `[0.0, 1.0]` and be 311 relative to the *x-axis* length, *y-axis* width, and *z-axis* height of the SymPart with 312 its origin at the front, left, center of the part. 313 314 Parameters 315 ---------- 316 attachment_point_id : `str` 317 Unique identifier for the new attachment point. 318 x : `Union[float, sympy.Expr]` 319 Local x-axis placement of the attachment point on the SymPart relative to its length. 320 y : `Union[float, sympy.Expr]` 321 Local y-axis placement of the attachment point on the SymPart relative to its width. 322 z : `Union[float, sympy.Expr]` 323 Local z-axis placement of the attachment point on the SymPart relative to its height. 324 325 Returns 326 ------- 327 self : `SymPart` 328 The current SymPart being manipulated. 329 """ 330 if attachment_point_id in [point.name for point in self.attachment_points]: 331 raise ValueError('An attachment point with the ID "{}" already exists' 332 .format(attachment_point_id)) 333 self.attachment_points.append(Coordinate(attachment_point_id, x=x, y=y, z=z)) 334 return self
Adds a local attachment point to the SymPart.
Each coordinate of the attachment point should fall in the range [0.0, 1.0] and be
relative to the x-axis length, y-axis width, and z-axis height of the SymPart with
its origin at the front, left, center of the part.
Parameters
- attachment_point_id (
str): Unique identifier for the new attachment point. - x (
Union[float, sympy.Expr]): Local x-axis placement of the attachment point on the SymPart relative to its length. - y (
Union[float, sympy.Expr]): Local y-axis placement of the attachment point on the SymPart relative to its width. - z (
Union[float, sympy.Expr]): Local z-axis placement of the attachment point on the SymPart relative to its height.
Returns
- self (
SymPart): The current SymPart being manipulated.
337 def add_connection_port(self: SymPartSub, connection_port_id: str, *, 338 x: Union[float, Expr], 339 y: Union[float, Expr], 340 z: Union[float, Expr]) -> SymPartSub: 341 """Adds a local connection port to the SymPart. 342 343 Each coordinate of the connection port should fall in the range `[0.0, 1.0]` and be 344 relative to the *x-axis* length, *y-axis* width, and *z-axis* height of the SymPart with 345 its origin at the front, left, center of the part. 346 347 Parameters 348 ---------- 349 connection_port_id : `str` 350 Unique identifier for the new connection port. 351 x : `Union[float, sympy.Expr]` 352 Local x-axis placement of the connection port on the SymPart relative to its length. 353 y : `Union[float, sympy.Expr]` 354 Local y-axis placement of the connection port on the SymPart relative to its width. 355 z : `Union[float, sympy.Expr]` 356 Local z-axis placement of the connection port on the SymPart relative to its height. 357 358 Returns 359 ------- 360 self : `SymPart` 361 The current SymPart being manipulated. 362 """ 363 if connection_port_id in [port.name for port in self.connection_ports]: 364 raise ValueError('A connection port with the ID "{}" already exists' 365 .format(connection_port_id)) 366 self.connection_ports.append(Coordinate(connection_port_id, x=x, y=y, z=z)) 367 return self
Adds a local connection port to the SymPart.
Each coordinate of the connection port should fall in the range [0.0, 1.0] and be
relative to the x-axis length, y-axis width, and z-axis height of the SymPart with
its origin at the front, left, center of the part.
Parameters
- connection_port_id (
str): Unique identifier for the new connection port. - x (
Union[float, sympy.Expr]): Local x-axis placement of the connection port on the SymPart relative to its length. - y (
Union[float, sympy.Expr]): Local y-axis placement of the connection port on the SymPart relative to its width. - z (
Union[float, sympy.Expr]): Local z-axis placement of the connection port on the SymPart relative to its height.
Returns
- self (
SymPart): The current SymPart being manipulated.
370 def attach(self: SymPartSub, local_attachment_id: str, 371 remote_part: SymPart, 372 remote_attachment_id: str) -> SymPartSub: 373 """Creates a rigid attachment between a local and remote attachment point. 374 375 Parameters 376 ---------- 377 local_attachment_id : `str` 378 Identifier of the local attachment point to which to attach. 379 remote_part : `SymPart` 380 The remote SymPart to which to make an attachment. 381 remote_attachment_id : `str` 382 Identifier of the remote attachment point to which to attach. 383 384 Returns 385 ------- 386 self : `SymPart` 387 The current SymPart being manipulated. 388 """ 389 390 # Ensure that the requested attachment is valid 391 if self.name == remote_part.name: 392 raise ValueError('The local and attached parts cannot both have the same name "{}"' 393 .format(self.name)) 394 if local_attachment_id not in [point.name for point in self.attachment_points]: 395 raise ValueError('The local attachment point identifier "{}" does not exist' 396 .format(local_attachment_id)) 397 if remote_attachment_id not in [point.name for point in remote_part.attachment_points]: 398 raise ValueError('The remote attachment point identifier "{}" does not exist' 399 .format(remote_attachment_id)) 400 if local_attachment_id in self.attachments: 401 raise ValueError('The local attachment point "{}" is already being used' 402 .format(local_attachment_id)) 403 if remote_attachment_id in remote_part.attachments: 404 raise ValueError('The remote attachment point "{}" is already being used' 405 .format(remote_attachment_id)) 406 407 # Make the rigid attachment in both directions 408 self.attachments[local_attachment_id] = remote_part.name + '#' + remote_attachment_id 409 remote_part.attachments[remote_attachment_id] = self.name + '#' + local_attachment_id 410 return self
Creates a rigid attachment between a local and remote attachment point.
Parameters
- local_attachment_id (
str): Identifier of the local attachment point to which to attach. - remote_part (
SymPart): The remote SymPart to which to make an attachment. - remote_attachment_id (
str): Identifier of the remote attachment point to which to attach.
Returns
- self (
SymPart): The current SymPart being manipulated.
413 def connect(self: SymPartSub, local_connection_id: str, 414 remote_part: SymPart, 415 remote_connection_id: str) -> SymPartSub: 416 """Creates a non-rigid connection between a local and remote connection port. 417 418 Parameters 419 ---------- 420 local_connection_id : `str` 421 Identifier of the local connection port to which to connect. 422 remote_part : `SymPart` 423 The remote SymPart to which to make a connection. 424 remote_connection_id : `str` 425 Identifier of the remote connection port to which to connect. 426 427 Returns 428 ------- 429 self : `SymPart` 430 The current SymPart being manipulated. 431 """ 432 433 # Ensure that the requested connection is valid 434 if self.name == remote_part.name: 435 raise ValueError('The local and connected parts cannot both have the same name "{}"' 436 .format(self.name)) 437 if local_connection_id not in [port.name for port in self.connection_ports]: 438 raise ValueError('The local connection port identifier "{}" does not exist' 439 .format(local_connection_id)) 440 if remote_connection_id not in [port.name for port in remote_part.connection_ports]: 441 raise ValueError('The remote connection port identifier "{}" does not exist' 442 .format(remote_connection_id)) 443 if local_connection_id in self.connections: 444 raise ValueError('The local connection port "{}" is already being used' 445 .format(local_connection_id)) 446 if remote_connection_id in remote_part.connections: 447 raise ValueError('The remote connection port "{}" is already being used' 448 .format(remote_connection_id)) 449 450 # Make the flexible connection in both directions 451 self.connections[local_connection_id] = remote_part.name + '#' + remote_connection_id 452 remote_part.connections[remote_connection_id] = self.name + '#' + local_connection_id 453 return self
Creates a non-rigid connection between a local and remote connection port.
Parameters
- local_connection_id (
str): Identifier of the local connection port to which to connect. - remote_part (
SymPart): The remote SymPart to which to make a connection. - remote_connection_id (
str): Identifier of the remote connection port to which to connect.
Returns
- self (
SymPart): The current SymPart being manipulated.
456 def get_cad_physical_properties(self, 457 normalize_origin: Optional[bool] = False) -> Dict[str, float]: 458 """Retrieves the set of physical properties of the SymPart as reported by the underlying 459 CAD model. 460 461 Parameters 462 ---------- 463 normalize_origin : `bool`, optional, default=False 464 Return physical properties with respect to the front, left, bottom corner of the 465 underlying CAD model. 466 467 Returns 468 ------- 469 `Dict[str, float]` 470 A list of physical properties as calculated from the underlying CAD model. 471 """ 472 placement_center = self.static_origin.as_tuple() if \ 473 self.static_origin is not None else \ 474 (0.0, 0.0, 0.0) 475 return self.__cad__.get_physical_properties(self.geometry.__dict__, 476 placement_center, 477 self.orientation.as_tuple(), 478 self.material_density, 479 normalize_origin)
Retrieves the set of physical properties of the SymPart as reported by the underlying CAD model.
Parameters
- normalize_origin (
bool, optional, default=False): Return physical properties with respect to the front, left, bottom corner of the underlying CAD model.
Returns
Dict[str, float]: A list of physical properties as calculated from the underlying CAD model.
482 def export(self, save_path: str, export_type: Literal['freecad', 'step', 'stl']) -> None: 483 """Exports the SymPart to an external CAD representation. 484 485 Supported CAD model formats currently include FreeCAD, STEP, and STL. 486 487 Parameters 488 ---------- 489 save_path : `str` 490 Output file path at which to store the the generated CAD model. 491 export_type : {'freecad', 'step', 'stl'} 492 Format of the CAD model to export. 493 """ 494 placement_center = self.static_origin.as_tuple() if \ 495 self.static_origin is not None else \ 496 (0.0, 0.0, 0.0) 497 self.__cad__.export_model(save_path, 498 export_type, 499 self.geometry.__dict__, 500 placement_center, 501 self.orientation.as_tuple())
Exports the SymPart to an external CAD representation.
Supported CAD model formats currently include FreeCAD, STEP, and STL.
Parameters
- save_path (
str): Output file path at which to store the the generated CAD model. - export_type ({'freecad', 'step', 'stl'}): Format of the CAD model to export.
506 @abc.abstractmethod 507 def set_geometry(self: SymPartSub, **kwargs) -> SymPartSub: 508 """Abstract method that must be overridden by a concrete `SymPart` class to allow setting 509 its physical geometry. 510 511 Parameters 512 ---------- 513 **kwargs : `Dict` 514 Set of named parameters that define the geometry of a SymPart. 515 516 Raises 517 ------- 518 NotImplementedError 519 If the implementing `SymPart` class does not override this method. 520 """ 521 raise NotImplementedError
523 @abc.abstractmethod 524 def get_geometric_parameter_bounds(self: SymPartSub, parameter: str) -> Tuple[float, float]: 525 """Abstract method that must be overridden by a concrete `SymPart` class to return the 526 minimum and maximum expected bounds for a given geometric parameter. 527 528 Parameters 529 ---------- 530 parameter : `str` 531 Name of the geometric parameter for which to return the minimum and maximum bounds. 532 533 Returns 534 ------- 535 `Tuple[float, float]` 536 Minimum and maximum bounds for the specified geometric `parameter`.""" 537 raise NotImplementedError
Abstract method that must be overridden by a concrete SymPart class to return the
minimum and maximum expected bounds for a given geometric parameter.
Parameters
- parameter (
str): Name of the geometric parameter for which to return the minimum and maximum bounds.
Returns
Tuple[float, float]: Minimum and maximum bounds for the specified geometricparameter.
539 def get_valid_states(self: SymPartSub) -> List[str]: 540 """Method that may be overridden by a concrete `SymPart` class to indicate the list of 541 geometric state names recognized by the part. 542 543 If this method is not overridden, it will return an empty list, indicating that the part 544 only has one valid geometric state. 545 """ 546 return []
Method that may be overridden by a concrete SymPart class to indicate the list of
geometric state names recognized by the part.
If this method is not overridden, it will return an empty list, indicating that the part only has one valid geometric state.
Material volume (in m^3) of the SymPart (read-only).
Displaced volume (in m^3) of the SymPart (read-only).
Surface/wetted area (in m^2) of the SymPart (read-only).
Center of gravity (in m) of the unoriented SymPart (read-only).
Center of gravity (in m) of the oriented SymPart (read-only).
Center of buoyancy (in m) of the unoriented SymPart (read-only).
Center of buoyancy (in m) of the oriented SymPart (read-only).
X-axis length (in m) of the bounding box of the unoriented SymPart (read-only).
Y-axis width (in m) of the bounding box of the unoriented SymPart (read-only).
Z-axis height (in m) of the bounding box of the unoriented SymPart (read-only).
X-axis length (in m) of the bounding box of the oriented SymPart (read-only).