Source code for anastruct.preprocess.beam_class

import warnings
from abc import ABC, abstractmethod
from typing import Iterable, Literal, Optional, Sequence, Union, cast

import numpy as np

from anastruct._types import LoadDirection, SectionProps
from anastruct.fem.system import SystemElements
from anastruct.fem.system_components.util import add_node
from anastruct.vertex import Vertex

[docs] DEFAULT_BEAM_SECTION: SectionProps = { "EI": 1e6, "EA": 1e8, "g": 0.0, }
[docs] class Beam(ABC): """Abstract base class for 2D beam structures. Provides a framework for creating parametric beam geometries with automated node generation, connectivity, and support definitions. Subclasses implement specific beam types (simple, cantilever, etc.). The beam generation follows a two-phase process: 1. define_nodes() - Generate node coordinates and span connectivity 2. define_supports() - Define support locations and types Attributes: length (float): Total length of the beam (length units) angle (float): Angle of the beam (degrees; 0 = horizontal, positive = CCW); defaults to 0.0 section (SectionProps): Section properties for all beam elements; defaults to DEFAULT_BEAM_SECTION supports_type (Literal["simple", "pinned", "fixed"]): Type of supports to apply; defaults to "simple" system (SystemElements): The FEM system containing all nodes, elements, and supports """ # Common geometry
[docs] length: float
[docs] span_lengths: list[float]
[docs] angle: float
# Material properties
[docs] section: SectionProps
# Defined by subclass (initialized in define_* methods)
[docs] nodes: list[Vertex]
[docs] node_ids: dict[int, list[int]]
[docs] support_definitions: dict[int, Literal["fixed", "pinned", "roller"]]
# Defined by main class (initialized in add_elements)
[docs] element_ids: dict[int, list[int]]
# System
[docs] system: SystemElements
def __init__( self, length: Optional[float] = None, span_lengths: Optional[list[float]] = None, angle: float = 0.0, section: Optional[SectionProps] = None, ): """Initialize a beam structure. Args: length (float): Total length of the beam (length units). Must be positive. Either length or span_lengths must be provided. span_lengths (list[float]): List of span lengths for each span. Must be positive. Either length or span_lengths must be provided. angle (float): Angle of the beam (degrees; 0 = horizontal, positive = CCW); defaults to 0.0 section (SectionProps): Section properties for all beam elements; defaults to DEFAULT_BEAM_SECTION Raises: ValueError: If length or span_lengths are not positive, or if neither (or both) are provided. """ if length is None and span_lengths is None: raise ValueError("Either length or span_lengths must be provided.") if length is not None and span_lengths is not None: raise ValueError("Only one of length or span_lengths may be provided.") if span_lengths is not None: if any(l <= 0 for l in span_lengths): raise ValueError( f"All span lengths must be positive, got {span_lengths}" ) self.span_lengths = span_lengths self.length = sum(span_lengths) if length is not None: if length <= 0: raise ValueError(f"length must be positive, got {length}") self.length = length self.span_lengths = [length] if angle != 0.0 and -2 * np.pi <= angle <= 2 * np.pi: warnings.warn( f"WARNING: A very small angle was provided ({angle}). " f"Please ensure input units are degrees, not radians.", stacklevel=2, ) if angle < 0 or angle >= 360: angle = angle % 360 self.angle = angle self.section = section or DEFAULT_BEAM_SECTION def ensure_valid_section(section: SectionProps) -> SectionProps: """Ensure section has all required properties, filling in defaults.""" valid_section = dict(DEFAULT_BEAM_SECTION) # Start with defaults valid_section.update(section) # Override with provided values return cast(SectionProps, valid_section) self.section = ensure_valid_section(self.section)
[docs] self.dx = np.cos(self.angle * np.pi / 180)
[docs] self.dy = np.sin(self.angle * np.pi / 180)
# Initialize mutable attributes (prevents sharing between instances) self.nodes = [] self.node_ids = {} self.support_definitions = {} self.element_ids = {} self.define_nodes() self.define_supports() self.system = SystemElements() self.add_nodes() self.add_elements() self.add_supports() @property @abstractmethod
[docs] def type(self) -> str: """Return the human-readable name of the beam type."""
@abstractmethod
[docs] def define_nodes(self) -> None: """Generate node coordinates and populate self.nodes list. Must be implemented by subclasses. Should create Vertex objects representing all node locations in the beam. Should also populate self.node_ids dictionary mapping spanwise node indices to global node IDs. """
@abstractmethod
[docs] def define_supports(self) -> None: """Define support locations and types by populating self.support_definitions. Must be implemented by subclasses. """
[docs] def add_nodes(self) -> None: """Add all nodes from self.nodes to the SystemElements.""" for i, vertex in enumerate(self.nodes): add_node(self.system, point=vertex, node_id=i + 1)
[docs] def add_elements(self) -> None: """Create elements from connectivity definitions and add to SystemElements. Populates element ID list self.element_ids. """ def add_span_elements( node_pairs: Iterable[tuple[int, int]], section: SectionProps, ) -> list[int]: """Helper to add a sequence of connected elements. Args: node_pairs (Iterable[tuple[int, int]]): Pairs of node IDs to connect section (SectionProps): Section properties for the elements Returns: list[int]: Element IDs of created elements """ element_ids = [] for i, j in node_pairs: element_ids.append( self.system.add_element( location=(self.nodes[i - 1], self.nodes[j - 1]), EA=section["EA"], EI=section["EI"], g=section["g"], spring=None, ) ) return element_ids # Element creation per span self.element_ids = {} for span, span_node_ids in self.node_ids.items(): self.element_ids[span] = add_span_elements( node_pairs=zip(span_node_ids[:-1], span_node_ids[1:]), section=self.section, )
[docs] def add_supports(self) -> None: """Add supports from self.support_definitions to the SystemElements.""" for node_id, support_type in self.support_definitions.items(): if support_type == "fixed": self.system.add_support_fixed(node_id=node_id) elif support_type == "pinned": self.system.add_support_hinged(node_id=node_id) elif support_type == "roller": self.system.add_support_roll(node_id=node_id)
[docs] def get_element_ids_of_spans( self, spans: Optional[Union[int, Sequence[int]]] ) -> list[int]: """Get element IDs for a span. Args: span_ids (int, sequence, None): The ID of the span to query. If None, returns element IDs for all spans. If a sequence, returns IDs for all specified spans. Returns: list[int]: Element IDs of the requested span Raises: KeyError: If span_id does not exist """ # Normalize spans to a list if spans is None: # Assume all spans by default spans = list(self.element_ids.keys()) elif isinstance(spans, int): spans = [spans] element_ids: list[int] = [] for span in spans: if span not in self.element_ids: available = list(self.element_ids.keys()) raise KeyError( f"span number '{span}' not found. " f"Available spans: {available}" ) element_ids.extend(self.element_ids[span]) return element_ids
[docs] def apply_q_load_to_spans( self, q: Union[float, Sequence[float]], direction: Union[LoadDirection, Sequence[LoadDirection]] = "element", rotation: Optional[Union[float, Sequence[float]]] = None, q_perp: Optional[Union[float, Sequence[float]]] = None, spans: Optional[Union[int, Sequence[int]]] = None, ) -> None: """Apply distributed load to all elements within one or more spans. Args: q (Union[float, Sequence[float]]): Load magnitude (force/length units) direction (Union[LoadDirection, Sequence[LoadDirection]]): Load direction. Options: "element", "x", "y", "parallel", "perpendicular", "angle" rotation (Optional[Union[float, Sequence[float]]]): Rotation angle in degrees (used with direction="angle") q_perp (Optional[Union[float, Sequence[float]]]): Perpendicular load component """ element_ids = self.get_element_ids_of_spans(spans=spans) for el_id in element_ids: self.system.q_load( element_id=el_id, q=q, direction=direction, rotation=rotation, q_perp=q_perp, )
[docs] def apply_point_load_to_spans( self, Fx: Union[float, Sequence[float]] = 0.0, Fy: Union[float, Sequence[float]] = 0.0, rotation: Union[float, Sequence[float]] = 0.0, absolute_location: Optional[float] = None, relative_location: Optional[float] = None, spans: Optional[Union[int, Sequence[int]]] = None, tolerance: Optional[float] = None, ) -> None: """Apply point load to elements within one or more spans. Args: Fx (Union[float, Sequence[float]]): Horizontal load component (force units) Fy (Union[float, Sequence[float]]): Vertical load component (force units) rotation (Union[float, Sequence[float]]): Rotation angle in degrees absolute_location (Optional[float]): Absolute location along the beam length (length units). Either absolute_location or relative_location must be provided. relative_location (Optional[float]): Relative location along the beam length (0.0 = start of span, 1.0 = end of span). Either absolute_location or relative_location must be provided. spans (Optional[Union[int, Sequence[int]]]): Span(s) to apply the load to. If None, applies to all spans. tolerance (float): Tolerance for matching existing node locations (length units). Defaults to beam length * 1e-4. """ if spans is None: spans = list(self.element_ids.keys()) elif isinstance(spans, int): spans = [spans] if absolute_location is None and relative_location is None: raise ValueError( "Either absolute_location or relative_location must be provided." ) if absolute_location is not None and relative_location is not None: raise ValueError( "Only one of absolute_location or relative_location may be provided." ) if tolerance is None: tolerance = self.length * 1e-4 for span in spans: span_node_ids = self.node_ids[span] span_start = self.nodes[span_node_ids[0] - 1] span_end = self.nodes[span_node_ids[-1] - 1] span_length = np.sqrt( (span_end.x - span_start.x) ** 2 + (span_end.y - span_start.y) ** 2 ) if relative_location is not None: # Check if location is within this span if relative_location < 0 or relative_location > 1.0: continue # Compute absolute location within the span span_abs_location = relative_location * span_length else: assert absolute_location is not None span_abs_location = absolute_location # Compute load location load_x = span_start.x + self.dx * span_abs_location load_y = span_start.y + self.dy * span_abs_location # Determine if a node already exists at (or very near to) the load location node_id = self.system.find_node_id( vertex=Vertex(load_x, load_y), tolerance=tolerance ) # If no existing node, insert a new node into the appropriate element if node_id is None: # Identify the element to insert the node into elem_start = 0.0 for i, elem_id in enumerate(self.element_ids[span]): elem_start_v = self.system.element_map[elem_id].vertex_1 elem_end_v = self.system.element_map[elem_id].vertex_2 elem_length = np.sqrt( (elem_end_v.x - elem_start_v.x) ** 2 + (elem_end_v.y - elem_start_v.y) ** 2 ) elem_end = elem_start + elem_length if elem_start <= span_abs_location <= elem_end: # Insert node into this element result = self.system.insert_node( element_id=elem_id, location=Vertex(load_x, load_y) ) # Update our internal node and element lists self.node_ids[span].insert(i + 1, result["new_node_id"]) self.element_ids[span].remove(elem_id) self.element_ids[span].insert(i, result["new_element_id1"]) self.element_ids[span].insert(i + 1, result["new_element_id2"]) node_id = result["new_node_id"] break # Apply point load at the identified or newly created node assert node_id is not None self.system.point_load(node_id=node_id, Fx=Fx, Fy=Fy, rotation=rotation)
[docs] def validate(self) -> bool: """Validate beam geometry and connectivity. Checks for common beam definition issues: - All node IDs in span lists reference valid nodes - No duplicate nodes at the same location - All elements have non-zero length Returns: bool: True if validation passes Raises: ValueError: If validation fails with description of the issue """ # Check that all node IDs in connectivity are valid max_node_id = len(self.nodes) # Validate node ID list for span, span_node_ids in self.node_ids.items(): for node_id in span_node_ids: if node_id < 1 or node_id > max_node_id: raise ValueError( f"Span number '{span}' references invalid node ID {node_id}. " f"Valid range: 1-{max_node_id}" ) # Check for duplicate node locations (within tolerance) tolerance = 1e-6 for i, node_i in enumerate(self.nodes): for j in range(i + 1, len(self.nodes)): node_j = self.nodes[j] dx = abs(node_i.x - node_j.x) dy = abs(node_i.y - node_j.y) if dx < tolerance and dy < tolerance: raise ValueError( f"Duplicate nodes at position ({node_i.x:.6f}, {node_i.y:.6f}): " f"node {i} and node {j}" ) # Check for zero-length elements def check_element_length( node_a_id: int, node_b_id: int, element_type: str ) -> None: node_a = self.nodes[node_a_id - 1] node_b = self.nodes[node_b_id - 1] dx = node_b.x - node_a.x dy = node_b.y - node_a.y length = np.sqrt(dx**2 + dy**2) if length < tolerance: raise ValueError( f"Zero-length element in {element_type}: nodes {node_a_id} and {node_b_id} " f"at position ({node_a.x:.6f}, {node_a.y:.6f})" ) # Check span elements for span, span_node_ids in self.node_ids.items(): for i in range(len(span_node_ids) - 1): node_a = span_node_ids[i] node_b = span_node_ids[i + 1] check_element_length(node_a, node_b, f"span {span}") return True
[docs] def show_structure(self) -> None: """Display the beam structure using matplotlib.""" self.system.show_structure()