diff --git a/anastruct/__init__.py b/anastruct/__init__.py index 028b3d95..6e5fdc0d 100644 --- a/anastruct/__init__.py +++ b/anastruct/__init__.py @@ -1,4 +1,4 @@ from anastruct.fem.system import SystemElements from anastruct.fem.util.load import LoadCase, LoadCombination -from anastruct.preprocess import truss +from anastruct.preprocess import beam, truss from anastruct.vertex import Vertex diff --git a/anastruct/types.py b/anastruct/_types.py similarity index 100% rename from anastruct/types.py rename to anastruct/_types.py diff --git a/anastruct/_version.py b/anastruct/_version.py index 51bbb3f2..14d9d2f5 100644 --- a/anastruct/_version.py +++ b/anastruct/_version.py @@ -1 +1 @@ -__version__ = "1.6.2" +__version__ = "1.7.0" diff --git a/anastruct/fem/elements.py b/anastruct/fem/elements.py index d47d2b47..b204c418 100644 --- a/anastruct/fem/elements.py +++ b/anastruct/fem/elements.py @@ -10,9 +10,9 @@ from anastruct.basic import FEMException if TYPE_CHECKING: + from anastruct._types import ElementType from anastruct.fem.node import Node from anastruct.fem.system import Spring - from anastruct.types import ElementType from anastruct.vertex import Vertex try: diff --git a/anastruct/fem/system.py b/anastruct/fem/system.py index 2184693c..ae30419f 100644 --- a/anastruct/fem/system.py +++ b/anastruct/fem/system.py @@ -30,8 +30,7 @@ if TYPE_CHECKING: from matplotlib.figure import Figure - from anastruct.fem.node import Node - from anastruct.types import ( + from anastruct._types import ( AxisNumber, Dimension, LoadDirection, @@ -40,6 +39,7 @@ SupportDirection, VertexLike, ) + from anastruct.fem.node import Node class SystemElements: @@ -646,7 +646,7 @@ def insert_node( element_id: int, location: Optional["VertexLike"] = None, factor: Optional[float] = None, - ) -> None: + ) -> dict[str, int]: """Insert a node into an existing structure. This can be done by adding a new Vertex at any given location, or by setting a factor of the elements length. E.g. if you want a node at 40% of the elements @@ -658,6 +658,13 @@ def insert_node( Defaults to None. factor (Optional[float], optional): Fraction of distance from start to end of elmeent on which to divide the element. Must be between 0 and 1. Defaults to None. + + Returns: + dict[str, int]: Dictionary with keys: + 'new_node_id': ID of the newly created node + 'new_element_id1': ID of the first new element created + 'new_element_id2': ID of the second new element created + 'old_element_id': ID of the old element that was split """ element_id_to_split = _negative_index_to_id(element_id, self.element_map) element_to_split = self.element_map[element_id_to_split] @@ -753,6 +760,13 @@ def insert_node( # Remove the old element from everywhere it's referenced self.remove_element(element_id_to_split) + return { + "new_node_id": self.id_last_node, + "new_element_id1": element_id1, + "new_element_id2": element_id2, + "old_element_id": element_id_to_split, + } + def insert_node_old( self, element_id: int, @@ -2114,12 +2128,14 @@ def get_node_result_range(self, unit: Literal["ux", "uy", "phi_z"]) -> List[floa return [node.phi_z for node in self.node_map.values()] raise NotImplementedError - def find_node_id(self, vertex: Union[Vertex, Sequence[float]]) -> Optional[int]: + def find_node_id( + self, vertex: Union[Vertex, Sequence[float]], tolerance: float = 1e-9 + ) -> Optional[int]: """Find the ID of a certain location. Args: vertex (Union[Vertex, Sequence[float]]): Vertex_xz, [x, y], (x, y) - + tolerance (float): Tolerance for matching existing node locations (length units). Defaults to 1e-9. Raises: TypeError: vertex must be a list, tuple or Vertex @@ -2128,11 +2144,10 @@ def find_node_id(self, vertex: Union[Vertex, Sequence[float]]) -> Optional[int]: """ vertex_v = Vertex(vertex) try: - tol = 1e-9 return next( filter( - lambda x: math.isclose(x.vertex.x, vertex_v.x, abs_tol=tol) - and math.isclose(x.vertex.y, vertex_v.y, abs_tol=tol), + lambda x: math.isclose(x.vertex.x, vertex_v.x, abs_tol=tolerance) + and math.isclose(x.vertex.y, vertex_v.y, abs_tol=tolerance), self.node_map.values(), ) ).id diff --git a/anastruct/fem/system_components/assembly.py b/anastruct/fem/system_components/assembly.py index 416bdc3b..461f7191 100644 --- a/anastruct/fem/system_components/assembly.py +++ b/anastruct/fem/system_components/assembly.py @@ -6,9 +6,9 @@ from anastruct.fem.elements import det_axial, det_moment, det_shear if TYPE_CHECKING: + from anastruct._types import AxisNumber from anastruct.fem.elements import Element from anastruct.fem.system import SystemElements - from anastruct.types import AxisNumber def set_force_vector( diff --git a/anastruct/fem/system_components/util.py b/anastruct/fem/system_components/util.py index 4dd0b3fc..3c35765c 100644 --- a/anastruct/fem/system_components/util.py +++ b/anastruct/fem/system_components/util.py @@ -7,8 +7,8 @@ from anastruct.vertex import Vertex if TYPE_CHECKING: + from anastruct._types import VertexLike from anastruct.fem.system import MpType, Spring, SystemElements - from anastruct.types import VertexLike def check_internal_hinges(system: "SystemElements", node_id: int) -> None: diff --git a/anastruct/preprocess/beam.py b/anastruct/preprocess/beam.py new file mode 100644 index 00000000..15072fa6 --- /dev/null +++ b/anastruct/preprocess/beam.py @@ -0,0 +1,390 @@ +from typing import Any, Literal, Optional + +from anastruct._types import SectionProps +from anastruct.preprocess.beam_class import Beam +from anastruct.vertex import Vertex + + +class SimpleBeam(Beam): + """Simple beam with a pin support at one end, and a roller support at the other.""" + + def __init__( + self, + length: float, + angle: float = 0.0, + section: Optional[SectionProps] = None, + ) -> None: + super().__init__( + length=length, + angle=angle, + section=section, + ) + + @property + def type(self) -> str: + return "Simple Beam" + + def define_nodes(self) -> None: + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.dx * self.length, self.dy * self.length)) + self.node_ids[0] = [1, 2] + + def define_supports(self) -> None: + self.support_definitions[1] = "pinned" + self.support_definitions[2] = "roller" + + +class CantileverBeam(Beam): + """Cantilever beam with a fixed support at one end and free at the other. + + The ``cantilever_side`` parameter specifies which end is the free (unsupported) + end. For example, ``cantilever_side="right"`` means the right end is free and + the left end is fixed. + """ + + def __init__( + self, + length: float, + cantilever_side: Literal["left", "right"] = "right", + angle: float = 0.0, + section: Optional[SectionProps] = None, + ) -> None: + self.cantilever_side = cantilever_side.lower() + if self.cantilever_side not in ["left", "right"]: + raise ValueError( + "cantilever_side must be either 'left' or 'right', " + f"got '{cantilever_side}'" + ) + super().__init__( + length=length, + angle=angle, + section=section, + ) + + @property + def type(self) -> str: + return "Cantilever Beam" + + def define_nodes(self) -> None: + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.dx * self.length, self.dy * self.length)) + self.node_ids[0] = [1, 2] + + def define_supports(self) -> None: + self.support_definitions[2 if self.cantilever_side == "left" else 1] = "fixed" + + +class RightCantileverBeam(CantileverBeam): + """Cantilever beam with a fixed support at the left end and free (cantilevered) at the right.""" + + def __init__( + self, + length: float, + angle: float = 0.0, + section: Optional[SectionProps] = None, + ) -> None: + super().__init__( + length=length, + cantilever_side="right", + angle=angle, + section=section, + ) + + @property + def type(self) -> str: + return "Right Cantilever Beam" + + +class LeftCantileverBeam(CantileverBeam): + """Cantilever beam with a free (cantilevered) left end and a fixed support at the right.""" + + def __init__( + self, + length: float, + angle: float = 0.0, + section: Optional[SectionProps] = None, + ) -> None: + super().__init__( + length=length, + cantilever_side="left", + angle=angle, + section=section, + ) + + @property + def type(self) -> str: + return "Left Cantilever Beam" + + +class MultiSpanBeam(Beam): + """Simply supported multi-span beam. Assumes equal spans unless span_lengths provided.""" + + def __init__( + self, + length: Optional[float] = None, + num_spans: Optional[int] = None, + span_lengths: Optional[list[float]] = None, + cantilevers: Optional[Literal["left", "right", "both"]] = None, + angle: float = 0.0, + section: Optional[SectionProps] = None, + ) -> None: + if span_lengths is None and num_spans is None: + raise ValueError("Either num_spans or span_lengths must be provided.") + if span_lengths is not None and num_spans is not None: + raise ValueError("Only one of num_spans or span_lengths may be provided.") + if num_spans is not None and length is None: + raise ValueError("If num_spans is provided, length must also be provided.") + if cantilevers not in [None, "left", "right", "both"]: + raise ValueError( + "cantilevers must be either None, 'left', 'right', or 'both', " + f"got '{cantilevers}'" + ) + if num_spans is not None and length is not None: + span_lengths = [length / num_spans] * num_spans + + # Set attributes needed by define_supports() before super().__init__() + self.num_spans = num_spans + self.cantilevers = cantilevers + + super().__init__( + span_lengths=span_lengths, + angle=angle, + section=section, + ) + + @property + def type(self) -> str: + return "Multi-Span Beam" + + def define_nodes(self) -> None: + current_length = 0.0 + self.nodes.append(Vertex(0.0, 0.0)) + for i, span in enumerate(self.span_lengths): + current_length += span + self.nodes.append( + Vertex( + self.dx * current_length, + self.dy * current_length, + ) + ) + self.node_ids[i] = [i + 1, i + 2] + + def define_supports(self) -> None: + first_support = 1 if self.cantilevers in [None, "right"] else 2 + last_support = ( + len(self.span_lengths) + 1 + if self.cantilevers in [None, "left"] + else len(self.span_lengths) + ) + self.support_definitions[first_support] = "pinned" + for i in range(first_support + 1, last_support + 1): + self.support_definitions[i] = "roller" + + +class TwoSpanBeam(MultiSpanBeam): + """Simply supported two-span beam with equal spans.""" + + def __init__( + self, + length: float, + angle: float = 0.0, + section: Optional[SectionProps] = None, + ) -> None: + super().__init__( + length=length, + num_spans=2, + angle=angle, + section=section, + ) + + @property + def type(self) -> str: + return "Two-Span Beam" + + +class ThreeSpanBeam(MultiSpanBeam): + """Simply supported three-span beam with equal spans.""" + + def __init__( + self, + length: float, + angle: float = 0.0, + section: Optional[SectionProps] = None, + ) -> None: + super().__init__( + length=length, + num_spans=3, + angle=angle, + section=section, + ) + + @property + def type(self) -> str: + return "Three-Span Beam" + + +class FourSpanBeam(MultiSpanBeam): + """Simply supported four-span beam with equal spans.""" + + def __init__( + self, + length: float, + angle: float = 0.0, + section: Optional[SectionProps] = None, + ) -> None: + super().__init__( + length=length, + num_spans=4, + angle=angle, + section=section, + ) + + @property + def type(self) -> str: + return "Four-Span Beam" + + +class ProppedBeam(MultiSpanBeam): + """Propped beam with an interior simple span and a cantilever on one side.""" + + def __init__( + self, + interior_length: float, + cantilever_length: float, + cantilever_side: Literal["left", "right"] = "right", + angle: float = 0.0, + section: Optional[SectionProps] = None, + ) -> None: + if cantilever_side.lower() == "left": + span_lengths = [cantilever_length, interior_length] + elif cantilever_side.lower() == "right": + span_lengths = [interior_length, cantilever_length] + else: + raise ValueError( + "cantilever_side must be either 'left' or 'right', " + f"got '{cantilever_side}'" + ) + super().__init__( + span_lengths=span_lengths, + cantilevers=cantilever_side, + angle=angle, + section=section, + ) + self.cantilever_side = cantilever_side.lower() + + @property + def type(self) -> str: + return "Propped Beam" + + +class RightProppedBeam(ProppedBeam): + """Propped beam with an interior simple span and a cantilever on the right side.""" + + def __init__( + self, + interior_length: float, + cantilever_length: float, + angle: float = 0.0, + section: Optional[SectionProps] = None, + ) -> None: + super().__init__( + interior_length=interior_length, + cantilever_length=cantilever_length, + cantilever_side="right", + angle=angle, + section=section, + ) + + @property + def type(self) -> str: + return "Right Propped Beam" + + +class LeftProppedBeam(ProppedBeam): + """Propped beam with an interior simple span and a cantilever on the left side.""" + + def __init__( + self, + interior_length: float, + cantilever_length: float, + angle: float = 0.0, + section: Optional[SectionProps] = None, + ) -> None: + super().__init__( + interior_length=interior_length, + cantilever_length=cantilever_length, + cantilever_side="left", + angle=angle, + section=section, + ) + + @property + def type(self) -> str: + return "Left Propped Beam" + + +def create_beam(beam_type: str, **kwargs: Any) -> Beam: + """Factory function to create beam instances by type name. + + Provides a convenient way to create beams without importing specific classes. + Type names are case-insensitive and can use underscores or hyphens as separators. + + Args: + beam_type (str): The type of beam to create (e.g., "simple", "cantilever", "multi_span") + **kwargs: Arguments to pass to the beam constructor + + Returns: + Beam: An instance of the requested beam type + + Raises: + ValueError: If beam_type is not recognized + + Examples: + >>> beam = create_beam("simple", length=10, section=section) + >>> beam = create_beam("cantilever", length=5, section=section) + """ + # Normalize the beam type name + normalized = beam_type.lower().replace("-", "_").replace(" ", "_") + + # Map of normalized names to classes + beam_map = { + # Single-span beams + "simple": SimpleBeam, + "cantilever": CantileverBeam, + "right_cantilever": RightCantileverBeam, + "left_cantilever": LeftCantileverBeam, + # Multi-span beams + "multispan": MultiSpanBeam, + "multi_span": MultiSpanBeam, + "two_span": TwoSpanBeam, + "three_span": ThreeSpanBeam, + "four_span": FourSpanBeam, + "propped": ProppedBeam, + "right_propped": RightProppedBeam, + "left_propped": LeftProppedBeam, + } + if normalized not in beam_map: + available = sorted(set(beam_map.keys())) + raise ValueError( + f"Unknown beam type '{beam_type}'. Available types: {', '.join(available)}" + ) + + beam_class = beam_map[normalized] + assert issubclass(beam_class, Beam) + return beam_class(**kwargs) + + +__all__ = [ + "SimpleBeam", + "CantileverBeam", + "RightCantileverBeam", + "LeftCantileverBeam", + "MultiSpanBeam", + "TwoSpanBeam", + "ThreeSpanBeam", + "FourSpanBeam", + "ProppedBeam", + "RightProppedBeam", + "LeftProppedBeam", + "create_beam", +] diff --git a/anastruct/preprocess/beam_class.py b/anastruct/preprocess/beam_class.py new file mode 100644 index 00000000..e438feb2 --- /dev/null +++ b/anastruct/preprocess/beam_class.py @@ -0,0 +1,436 @@ +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 + +DEFAULT_BEAM_SECTION: SectionProps = { + "EI": 1e6, + "EA": 1e8, + "g": 0.0, +} + + +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 + length: float + span_lengths: list[float] + angle: float + + # Material properties + section: SectionProps + + # Defined by subclass (initialized in define_* methods) + nodes: list[Vertex] + node_ids: dict[int, list[int]] + support_definitions: dict[int, Literal["fixed", "pinned", "roller"]] + + # Defined by main class (initialized in add_elements) + element_ids: dict[int, list[int]] + + # System + 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) + + self.dx = np.cos(self.angle * np.pi / 180) + 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 + def type(self) -> str: + """Return the human-readable name of the beam type.""" + + @abstractmethod + 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 + def define_supports(self) -> None: + """Define support locations and types by populating self.support_definitions. + + Must be implemented by subclasses. + """ + + 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) + + 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, + ) + + 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) + + 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 + + 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, + ) + + 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) + + 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 + + def show_structure(self) -> None: + """Display the beam structure using matplotlib.""" + self.system.show_structure() diff --git a/anastruct/preprocess/truss.py b/anastruct/preprocess/truss.py index 41e93f04..78fdad4c 100644 --- a/anastruct/preprocess/truss.py +++ b/anastruct/preprocess/truss.py @@ -2,8 +2,8 @@ import numpy as np +from anastruct._types import SectionProps from anastruct.preprocess.truss_class import FlatTruss, RoofTruss, Truss -from anastruct.types import SectionProps from anastruct.vertex import Vertex @@ -47,11 +47,11 @@ def define_connectivity(self) -> None: ) # Bottom chord connectivity - self.bottom_chord_node_ids = list(range(0, n_bottom_nodes)) + self.bottom_chord_node_ids = list(range(1, n_bottom_nodes + 1)) # Top chord connectivity self.top_chord_node_ids = list( - range(n_bottom_nodes, n_bottom_nodes + n_top_nodes) + range(n_bottom_nodes + 1, n_bottom_nodes + n_top_nodes + 1) ) # Web diagonals connectivity @@ -61,10 +61,8 @@ def define_connectivity(self) -> None: end_top = None if self.end_type == "triangle_up": # special case: end diagonal slopes in the opposite direction - self.web_node_pairs.append((0, n_bottom_nodes)) - self.web_node_pairs.append( - (n_bottom_nodes - 1, n_bottom_nodes + n_top_nodes - 1) - ) + self.web_node_pairs.append((1, n_bottom_nodes + 1)) + self.web_node_pairs.append((n_bottom_nodes, n_bottom_nodes + n_top_nodes)) start_top = 2 end_top = -3 elif self.end_type == "flat": @@ -141,11 +139,11 @@ def define_connectivity(self) -> None: ) # Bottom chord connectivity - self.bottom_chord_node_ids = list(range(0, n_bottom_nodes)) + self.bottom_chord_node_ids = list(range(1, n_bottom_nodes + 1)) # Top chord connectivity self.top_chord_node_ids = list( - range(n_bottom_nodes, n_bottom_nodes + n_top_nodes) + range(n_bottom_nodes + 1, n_bottom_nodes + n_top_nodes + 1) ) # Web diagonals connectivity @@ -155,10 +153,8 @@ def define_connectivity(self) -> None: end_top = None if self.end_type == "triangle_down": # special case: end diagonal slopes in the opposite direction - self.web_node_pairs.append((n_bottom_nodes, 0)) - self.web_node_pairs.append( - (n_bottom_nodes + n_top_nodes - 1, n_bottom_nodes - 1) - ) + self.web_node_pairs.append((n_bottom_nodes + 1, 1)) + self.web_node_pairs.append((n_bottom_nodes + n_top_nodes, n_bottom_nodes)) start_bot = 2 end_bot = -3 elif self.end_type == "flat": @@ -256,48 +252,57 @@ def __init__( self.end_width = (width - self.n_units * unit_width) / 2 + (unit_width / 2) def define_nodes(self) -> None: + # Warren's __init__ overrides self.end_width after super().__init__ returns, but + # define_nodes is called inside that super().__init__. At this point self.end_width + # still holds the FlatTruss value = (width - n_units*unit_width)/2, which equals the + # half-unit inset used for the *offset* chord endpoints (t_x0). The interior nodes of + # the *corner* chord sit an additional half unit further in, so their base is + # self.end_width + unit_width/2. + t_x0 = self.end_width # inset for offset-chord endpoints + b_base = ( + self.end_width + self.unit_width / 2 + ) # base x for corner-chord interior nodes + # Bottom chord nodes if self.end_type == "triangle_down": + # Corners at x=0 and x=width; n_units interior nodes self.nodes.append(Vertex(0.0, 0.0)) - else: - self.nodes.append(Vertex(self.end_width - self.unit_width / 2, 0.0)) - for i in range(int(self.n_units) + 1): - x = self.end_width + i * self.unit_width - self.nodes.append(Vertex(x, 0.0)) - if self.end_type == "triangle_down": + for i in range(int(self.n_units)): + self.nodes.append(Vertex(b_base + i * self.unit_width, 0.0)) self.nodes.append(Vertex(self.width, 0.0)) - else: - self.nodes.append( - Vertex(self.width - (self.end_width - self.unit_width / 2), 0.0) - ) + else: # triangle_up — offset endpoints; n_units-1 interior nodes + self.nodes.append(Vertex(t_x0, 0.0)) + for i in range(1, int(self.n_units)): + self.nodes.append(Vertex(t_x0 + i * self.unit_width, 0.0)) + self.nodes.append(Vertex(self.width - t_x0, 0.0)) # Top chord nodes if self.end_type == "triangle_up": - self.nodes.append(Vertex(0, self.height)) - else: - self.nodes.append(Vertex(self.end_width - self.unit_width / 2, self.height)) - for i in range(int(self.n_units) + 1): - x = self.end_width + i * self.unit_width - self.nodes.append(Vertex(x, self.height)) - if self.end_type == "triangle_up": + # Corners at x=0 and x=width; n_units interior nodes + self.nodes.append(Vertex(0.0, self.height)) + for i in range(int(self.n_units)): + self.nodes.append(Vertex(b_base + i * self.unit_width, self.height)) self.nodes.append(Vertex(self.width, self.height)) - else: - self.nodes.append( - Vertex(self.width - (self.end_width - self.unit_width / 2), self.height) - ) + else: # triangle_down — offset endpoints; n_units-1 interior nodes + self.nodes.append(Vertex(t_x0, self.height)) + for i in range(1, int(self.n_units)): + self.nodes.append(Vertex(t_x0 + i * self.unit_width, self.height)) + self.nodes.append(Vertex(self.width - t_x0, self.height)) def define_connectivity(self) -> None: + # triangle_down: bottom has corner endpoints → n_units+2 nodes; top is offset → n_units+1 + # triangle_up: top has corner endpoints → n_units+2 nodes; bottom is offset → n_units+1 n_bottom_nodes = int(self.n_units) + ( - 1 if self.end_type == "triangle_down" else 0 + 2 if self.end_type == "triangle_down" else 1 ) - n_top_nodes = int(self.n_units) + (1 if self.end_type == "triangle_up" else 0) + n_top_nodes = int(self.n_units) + (2 if self.end_type == "triangle_up" else 1) # Bottom chord connectivity - self.bottom_chord_node_ids = list(range(0, n_bottom_nodes)) + self.bottom_chord_node_ids = list(range(1, n_bottom_nodes + 1)) # Top chord connectivity self.top_chord_node_ids = list( - range(n_bottom_nodes, n_bottom_nodes + n_top_nodes) + range(n_bottom_nodes + 1, n_bottom_nodes + n_top_nodes + 1) ) # Web diagonals connectivity @@ -354,18 +359,18 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [0, 1, 2] - left_v = 0 - right_v = 2 + self.bottom_chord_node_ids = [1, 2, 3] + left_v = 1 + right_v = 3 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = {"left": [left_v, 3], "right": [3, right_v]} + self.top_chord_node_ids = {"left": [left_v, 4], "right": [4, right_v]} if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 4) # left overhang - self.top_chord_node_ids["right"].append(5) # right overhang + self.top_chord_node_ids["left"].insert(0, 5) # left overhang + self.top_chord_node_ids["right"].append(6) # right overhang # Web verticals connectivity - self.web_verticals_node_pairs.append((1, 3)) # center vertical + self.web_verticals_node_pairs.append((2, 4)) # center vertical class QueenPostRoofTruss(RoofTruss): @@ -408,27 +413,27 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [0, 1, 2] - left_v = 0 - right_v = 2 + self.bottom_chord_node_ids = [1, 2, 3] + left_v = 1 + right_v = 3 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = {"left": [left_v, 3, 4], "right": [4, 5, right_v]} + self.top_chord_node_ids = {"left": [left_v, 4, 5], "right": [5, 6, right_v]} if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 6) # left overhang - self.top_chord_node_ids["right"].append(7) # right overhang + self.top_chord_node_ids["left"].insert(0, 7) # left overhang + self.top_chord_node_ids["right"].append(8) # right overhang # Web diagonals connectivity self.web_node_pairs.append( - (1, 3) + (2, 4) ) # left diagonal from center bottom to left quarter top self.web_node_pairs.append( - (1, 5) + (2, 6) ) # right diagonal from center bottom to right quarter top - # Web verticals connectivity - Fixed: should connect to peak (node 4), not node 3 + # Web verticals connectivity - Fixed: should connect to peak (node 5), not node 4 self.web_verticals_node_pairs.append( - (1, 4) + (2, 5) ) # center vertical from center bottom to peak @@ -473,21 +478,21 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [0, 1, 2, 3] - left_v = 0 - right_v = 3 + self.bottom_chord_node_ids = [1, 2, 3, 4] + left_v = 1 + right_v = 4 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = {"left": [left_v, 4, 5], "right": [5, 6, right_v]} + self.top_chord_node_ids = {"left": [left_v, 5, 6], "right": [6, 7, right_v]} if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 7) # left overhang - self.top_chord_node_ids["right"].append(8) # right overhang + self.top_chord_node_ids["left"].insert(0, 8) # left overhang + self.top_chord_node_ids["right"].append(9) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((1, 4)) - self.web_node_pairs.append((1, 5)) self.web_node_pairs.append((2, 5)) self.web_node_pairs.append((2, 6)) + self.web_node_pairs.append((3, 6)) + self.web_node_pairs.append((3, 7)) class HoweRoofTruss(RoofTruss): @@ -532,24 +537,24 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [0, 1, 2, 3, 4] - left_v = 0 - right_v = 4 + self.bottom_chord_node_ids = [1, 2, 3, 4, 5] + left_v = 1 + right_v = 5 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = {"left": [left_v, 5, 6], "right": [6, 7, right_v]} + self.top_chord_node_ids = {"left": [left_v, 6, 7], "right": [7, 8, right_v]} if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 8) # left overhang - self.top_chord_node_ids["right"].append(9) # right overhang + self.top_chord_node_ids["left"].insert(0, 9) # left overhang + self.top_chord_node_ids["right"].append(10) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((2, 5)) # left diagonal - self.web_node_pairs.append((2, 7)) # right diagonal + self.web_node_pairs.append((3, 6)) # left diagonal + self.web_node_pairs.append((3, 8)) # right diagonal # Web verticals connectivity - self.web_verticals_node_pairs.append((1, 5)) # left vertical - self.web_verticals_node_pairs.append((2, 6)) # centre vertical - self.web_verticals_node_pairs.append((3, 7)) # right vertical + self.web_verticals_node_pairs.append((2, 6)) # left vertical + self.web_verticals_node_pairs.append((3, 7)) # centre vertical + self.web_verticals_node_pairs.append((4, 8)) # right vertical class PrattRoofTruss(RoofTruss): @@ -594,24 +599,24 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [0, 1, 2, 3, 4] - left_v = 0 - right_v = 4 + self.bottom_chord_node_ids = [1, 2, 3, 4, 5] + left_v = 1 + right_v = 5 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = {"left": [left_v, 5, 6], "right": [6, 7, right_v]} + self.top_chord_node_ids = {"left": [left_v, 6, 7], "right": [7, 8, right_v]} if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 8) # left overhang - self.top_chord_node_ids["right"].append(9) # right overhang + self.top_chord_node_ids["left"].insert(0, 9) # left overhang + self.top_chord_node_ids["right"].append(10) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((1, 6)) # left diagonal - self.web_node_pairs.append((3, 6)) # right diagonal + self.web_node_pairs.append((2, 7)) # left diagonal + self.web_node_pairs.append((4, 7)) # right diagonal # Web verticals connectivity - self.web_verticals_node_pairs.append((1, 5)) # left vertical - self.web_verticals_node_pairs.append((2, 6)) # centre vertical - self.web_verticals_node_pairs.append((3, 7)) # right vertical + self.web_verticals_node_pairs.append((2, 6)) # left vertical + self.web_verticals_node_pairs.append((3, 7)) # centre vertical + self.web_verticals_node_pairs.append((4, 8)) # right vertical class FanRoofTruss(RoofTruss): @@ -657,28 +662,28 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [0, 1, 2, 3] - left_v = 0 - right_v = 3 + self.bottom_chord_node_ids = [1, 2, 3, 4] + left_v = 1 + right_v = 4 # Top chord connectivity (left and right slopes stored separately) self.top_chord_node_ids = { - "left": [left_v, 4, 5, 6], - "right": [6, 7, 8, right_v], + "left": [left_v, 5, 6, 7], + "right": [7, 8, 9, right_v], } if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 9) # left overhang - self.top_chord_node_ids["right"].append(10) # right overhang + self.top_chord_node_ids["left"].insert(0, 10) # left overhang + self.top_chord_node_ids["right"].append(11) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((1, 4)) - self.web_node_pairs.append((1, 6)) - self.web_node_pairs.append((2, 6)) - self.web_node_pairs.append((2, 8)) + self.web_node_pairs.append((2, 5)) + self.web_node_pairs.append((2, 7)) + self.web_node_pairs.append((3, 7)) + self.web_node_pairs.append((3, 9)) # Web verticals connectivity - self.web_verticals_node_pairs.append((1, 5)) - self.web_verticals_node_pairs.append((2, 7)) + self.web_verticals_node_pairs.append((2, 6)) + self.web_verticals_node_pairs.append((3, 8)) class ModifiedQueenPostRoofTruss(RoofTruss): @@ -725,29 +730,29 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [0, 1, 2, 3, 4] - left_v = 0 - right_v = 4 + self.bottom_chord_node_ids = [1, 2, 3, 4, 5] + left_v = 1 + right_v = 5 # Top chord connectivity (left and right slopes stored separately) self.top_chord_node_ids = { - "left": [left_v, 5, 6, 7], - "right": [7, 8, 9, right_v], + "left": [left_v, 6, 7, 8], + "right": [8, 9, 10, right_v], } if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 10) # left overhang - self.top_chord_node_ids["right"].append(11) # right overhang + self.top_chord_node_ids["left"].insert(0, 11) # left overhang + self.top_chord_node_ids["right"].append(12) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((1, 5)) - self.web_node_pairs.append((1, 6)) self.web_node_pairs.append((2, 6)) - self.web_node_pairs.append((2, 8)) - self.web_node_pairs.append((3, 8)) + self.web_node_pairs.append((2, 7)) + self.web_node_pairs.append((3, 7)) self.web_node_pairs.append((3, 9)) + self.web_node_pairs.append((4, 9)) + self.web_node_pairs.append((4, 10)) # Web verticals connectivity - self.web_verticals_node_pairs.append((2, 7)) # center vertical + self.web_verticals_node_pairs.append((3, 8)) # center vertical class DoubleFinkRoofTruss(RoofTruss): @@ -795,28 +800,28 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [0, 1, 2, 3, 4, 5] - left_v = 0 - right_v = 5 + self.bottom_chord_node_ids = [1, 2, 3, 4, 5, 6] + left_v = 1 + right_v = 6 # Top chord connectivity (left and right slopes stored separately) self.top_chord_node_ids = { - "left": [left_v, 6, 7, 8], - "right": [8, 9, 10, right_v], + "left": [left_v, 7, 8, 9], + "right": [9, 10, 11, right_v], } if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 11) # left overhang - self.top_chord_node_ids["right"].append(12) # right overhang + self.top_chord_node_ids["left"].insert(0, 12) # left overhang + self.top_chord_node_ids["right"].append(13) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((1, 6)) - self.web_node_pairs.append((1, 7)) self.web_node_pairs.append((2, 7)) self.web_node_pairs.append((2, 8)) self.web_node_pairs.append((3, 8)) self.web_node_pairs.append((3, 9)) self.web_node_pairs.append((4, 9)) self.web_node_pairs.append((4, 10)) + self.web_node_pairs.append((5, 10)) + self.web_node_pairs.append((5, 11)) class DoubleHoweRoofTruss(RoofTruss): @@ -865,31 +870,31 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [0, 1, 2, 3, 4, 5, 6] - left_v = 0 - right_v = 6 + self.bottom_chord_node_ids = [1, 2, 3, 4, 5, 6, 7] + left_v = 1 + right_v = 7 # Top chord connectivity (left and right slopes stored separately) self.top_chord_node_ids = { - "left": [left_v, 7, 8, 9], - "right": [9, 10, 11, right_v], + "left": [left_v, 8, 9, 10], + "right": [10, 11, 12, right_v], } if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 12) # left overhang - self.top_chord_node_ids["right"].append(13) # right overhang + self.top_chord_node_ids["left"].insert(0, 13) # left overhang + self.top_chord_node_ids["right"].append(14) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((2, 7)) self.web_node_pairs.append((3, 8)) - self.web_node_pairs.append((3, 10)) + self.web_node_pairs.append((4, 9)) self.web_node_pairs.append((4, 11)) + self.web_node_pairs.append((5, 12)) # Web verticals connectivity - self.web_verticals_node_pairs.append((1, 7)) self.web_verticals_node_pairs.append((2, 8)) - self.web_verticals_node_pairs.append((3, 9)) # center vertical - self.web_verticals_node_pairs.append((4, 10)) + self.web_verticals_node_pairs.append((3, 9)) + self.web_verticals_node_pairs.append((4, 10)) # center vertical self.web_verticals_node_pairs.append((5, 11)) + self.web_verticals_node_pairs.append((6, 12)) class ModifiedFanRoofTruss(RoofTruss): @@ -938,31 +943,31 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [0, 1, 2, 3, 4] - left_v = 0 - right_v = 4 + self.bottom_chord_node_ids = [1, 2, 3, 4, 5] + left_v = 1 + right_v = 5 # Top chord connectivity (left and right slopes stored separately) self.top_chord_node_ids = { - "left": [left_v, 5, 6, 7, 8], - "right": [8, 9, 10, 11, right_v], + "left": [left_v, 6, 7, 8, 9], + "right": [9, 10, 11, 12, right_v], } if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 12) # left overhang - self.top_chord_node_ids["right"].append(13) # right overhang + self.top_chord_node_ids["left"].insert(0, 13) # left overhang + self.top_chord_node_ids["right"].append(14) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((1, 5)) - self.web_node_pairs.append((1, 7)) - self.web_node_pairs.append((2, 7)) - self.web_node_pairs.append((2, 9)) - self.web_node_pairs.append((3, 9)) - self.web_node_pairs.append((3, 11)) + self.web_node_pairs.append((2, 6)) + self.web_node_pairs.append((2, 8)) + self.web_node_pairs.append((3, 8)) + self.web_node_pairs.append((3, 10)) + self.web_node_pairs.append((4, 10)) + self.web_node_pairs.append((4, 12)) # Web verticals connectivity - self.web_verticals_node_pairs.append((1, 6)) - self.web_verticals_node_pairs.append((2, 8)) # center vertical - self.web_verticals_node_pairs.append((3, 10)) + self.web_verticals_node_pairs.append((2, 7)) + self.web_verticals_node_pairs.append((3, 9)) # center vertical + self.web_verticals_node_pairs.append((4, 11)) class AtticRoofTruss(RoofTruss): @@ -1142,53 +1147,53 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [0, 1, 2, 3] - left_v = 0 - right_v = 3 + self.bottom_chord_node_ids = [1, 2, 3, 4] + left_v = 1 + right_v = 4 if self.wall_ceiling_intersect: # Top chord connectivity (left and right slopes stored separately) self.top_chord_node_ids = { - "left": [left_v, 4, 5, 6], - "right": [6, 7, 8, right_v], - "ceiling": [5, 9, 7], # attic ceiling + "left": [left_v, 5, 6, 7], + "right": [7, 8, 9, right_v], + "ceiling": [6, 10, 8], # attic ceiling } if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 10) # left overhang - self.top_chord_node_ids["right"].append(11) # right overhang + self.top_chord_node_ids["left"].insert(0, 11) # left overhang + self.top_chord_node_ids["right"].append(12) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((1, 4)) + self.web_node_pairs.append((2, 5)) self.web_node_pairs.append( - (9, 6) + (10, 7) ) # special case: this is actually the center vertical post - self.web_node_pairs.append((2, 8)) + self.web_node_pairs.append((3, 9)) # Web verticals connectivity - self.web_verticals_node_pairs.append((1, 5)) - self.web_verticals_node_pairs.append((2, 7)) + self.web_verticals_node_pairs.append((2, 6)) + self.web_verticals_node_pairs.append((3, 8)) else: # Top chord connectivity (left and right slopes stored separately) self.top_chord_node_ids = { - "left": [left_v, 4, 5, 6, 7], - "right": [7, 8, 9, 10, right_v], - "ceiling": [6, 11, 8], # attic ceiling + "left": [left_v, 5, 6, 7, 8], + "right": [8, 9, 10, 11, right_v], + "ceiling": [7, 12, 9], # attic ceiling } if self.overhang_length > 0: - self.top_chord_node_ids["left"].insert(0, 12) # left overhang - self.top_chord_node_ids["right"].append(13) # right overhang + self.top_chord_node_ids["left"].insert(0, 13) # left overhang + self.top_chord_node_ids["right"].append(14) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((1, 4)) + self.web_node_pairs.append((2, 5)) self.web_node_pairs.append( - (11, 7) + (12, 8) ) # special case: this is actually the center vertical post - self.web_node_pairs.append((2, 10)) + self.web_node_pairs.append((3, 11)) # Web verticals connectivity - self.web_verticals_node_pairs.append((1, 5)) - self.web_verticals_node_pairs.append((2, 9)) + self.web_verticals_node_pairs.append((2, 6)) + self.web_verticals_node_pairs.append((3, 10)) def create_truss(truss_type: str, **kwargs: Any) -> "Truss": diff --git a/anastruct/preprocess/truss_class.py b/anastruct/preprocess/truss_class.py index 399325c4..83fd859d 100644 --- a/anastruct/preprocess/truss_class.py +++ b/anastruct/preprocess/truss_class.py @@ -1,11 +1,11 @@ from abc import ABC, abstractmethod -from typing import Iterable, Literal, Optional, Sequence, Union, overload +from typing import Iterable, Literal, Optional, Sequence, Union, cast, overload 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.types import LoadDirection, SectionProps from anastruct.vertex import Vertex DEFAULT_TRUSS_SECTION: SectionProps = { @@ -125,6 +125,17 @@ def __init__( self.bottom_chord_continuous = bottom_chord_continuous self.supports_type = supports_type + def ensure_valid_section(section: SectionProps) -> SectionProps: + """Ensure section has all required properties, filling in defaults.""" + valid_section = dict(DEFAULT_TRUSS_SECTION) # Start with defaults + valid_section.update(section) # Override with provided values + return cast(SectionProps, valid_section) + + self.top_chord_section = ensure_valid_section(self.top_chord_section) + self.bottom_chord_section = ensure_valid_section(self.bottom_chord_section) + self.web_section = ensure_valid_section(self.web_section) + self.web_verticals_section = ensure_valid_section(self.web_verticals_section) + # Initialize mutable attributes (prevents sharing between instances) self.nodes = [] self.web_node_pairs = [] @@ -176,7 +187,7 @@ def define_supports(self) -> None: 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) + add_node(self.system, point=vertex, node_id=i + 1) def add_elements(self) -> None: """Create elements from connectivity definitions and add to SystemElements. @@ -207,7 +218,7 @@ def add_segment_elements( for i, j in node_pairs: element_ids.append( self.system.add_element( - location=(self.nodes[i], self.nodes[j]), + location=(self.nodes[i - 1], self.nodes[j - 1]), EA=section["EA"], EI=section["EI"], g=section["g"], @@ -433,8 +444,8 @@ def validate(self) -> bool: 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) - 1 + # Check that all node IDs in connectivity are valid (1-based) + max_node_id = len(self.nodes) # Helper to validate node ID list def validate_node_ids( @@ -443,44 +454,44 @@ def validate_node_ids( if isinstance(node_ids, dict): for segment_name, ids in node_ids.items(): for node_id in ids: - if node_id < 0 or node_id > max_node_id: + if node_id < 1 or node_id > max_node_id: raise ValueError( f"{name} segment '{segment_name}' references invalid node ID {node_id}. " - f"Valid range: 0-{max_node_id}" + f"Valid range: 1-{max_node_id}" ) else: for node_id in node_ids: - if node_id < 0 or node_id > max_node_id: + if node_id < 1 or node_id > max_node_id: raise ValueError( f"{name} references invalid node ID {node_id}. " - f"Valid range: 0-{max_node_id}" + f"Valid range: 1-{max_node_id}" ) validate_node_ids(self.top_chord_node_ids, "top_chord_node_ids") validate_node_ids(self.bottom_chord_node_ids, "bottom_chord_node_ids") for i, (node_a, node_b) in enumerate(self.web_node_pairs): - if node_a < 0 or node_a > max_node_id: + if node_a < 1 or node_a > max_node_id: raise ValueError( f"web_node_pairs[{i}] references invalid node ID {node_a}. " - f"Valid range: 0-{max_node_id}" + f"Valid range: 1-{max_node_id}" ) - if node_b < 0 or node_b > max_node_id: + if node_b < 1 or node_b > max_node_id: raise ValueError( f"web_node_pairs[{i}] references invalid node ID {node_b}. " - f"Valid range: 0-{max_node_id}" + f"Valid range: 1-{max_node_id}" ) for i, (node_a, node_b) in enumerate(self.web_verticals_node_pairs): - if node_a < 0 or node_a > max_node_id: + if node_a < 1 or node_a > max_node_id: raise ValueError( f"web_verticals_node_pairs[{i}] references invalid node ID {node_a}. " - f"Valid range: 0-{max_node_id}" + f"Valid range: 1-{max_node_id}" ) - if node_b < 0 or node_b > max_node_id: + if node_b < 1 or node_b > max_node_id: raise ValueError( f"web_verticals_node_pairs[{i}] references invalid node ID {node_b}. " - f"Valid range: 0-{max_node_id}" + f"Valid range: 1-{max_node_id}" ) # Check for duplicate node locations (within tolerance) @@ -500,8 +511,8 @@ def validate_node_ids( def check_element_length( node_a_id: int, node_b_id: int, element_type: str ) -> None: - node_a = self.nodes[node_a_id] - node_b = self.nodes[node_b_id] + 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) @@ -675,7 +686,7 @@ def define_supports(self) -> None: """ assert isinstance(self.bottom_chord_node_ids, list) assert isinstance(self.top_chord_node_ids, list) - bottom_left = 0 + bottom_left = 1 bottom_right = max(self.bottom_chord_node_ids) top_left = min(self.top_chord_node_ids) top_right = max(self.top_chord_node_ids) @@ -784,7 +795,7 @@ def define_supports(self) -> None: """ assert isinstance(self.bottom_chord_node_ids, list) - bottom_left = 0 + bottom_left = 1 bottom_right = max(self.bottom_chord_node_ids) self.support_definitions[bottom_left] = self._resolve_support_type( is_primary=True diff --git a/anastruct/vertex.py b/anastruct/vertex.py index 458985bc..679efb5a 100644 --- a/anastruct/vertex.py +++ b/anastruct/vertex.py @@ -6,7 +6,7 @@ import numpy as np if TYPE_CHECKING: - from anastruct.types import NumberLike, VertexLike + from anastruct._types import NumberLike, VertexLike class Vertex: diff --git a/docs/source/img/preprocessing_beams/four_span_beam.png b/docs/source/img/preprocessing_beams/four_span_beam.png new file mode 100644 index 00000000..465dbffc Binary files /dev/null and b/docs/source/img/preprocessing_beams/four_span_beam.png differ diff --git a/docs/source/img/preprocessing_beams/left_cantilever_beam.png b/docs/source/img/preprocessing_beams/left_cantilever_beam.png new file mode 100644 index 00000000..44bea53c Binary files /dev/null and b/docs/source/img/preprocessing_beams/left_cantilever_beam.png differ diff --git a/docs/source/img/preprocessing_beams/left_propped_beam.png b/docs/source/img/preprocessing_beams/left_propped_beam.png new file mode 100644 index 00000000..eec053df Binary files /dev/null and b/docs/source/img/preprocessing_beams/left_propped_beam.png differ diff --git a/docs/source/img/preprocessing_beams/multi_span_beam.png b/docs/source/img/preprocessing_beams/multi_span_beam.png new file mode 100644 index 00000000..3c59b437 Binary files /dev/null and b/docs/source/img/preprocessing_beams/multi_span_beam.png differ diff --git a/docs/source/img/preprocessing_beams/right_cantilever_beam.png b/docs/source/img/preprocessing_beams/right_cantilever_beam.png new file mode 100644 index 00000000..cbb2844f Binary files /dev/null and b/docs/source/img/preprocessing_beams/right_cantilever_beam.png differ diff --git a/docs/source/img/preprocessing_beams/right_propped_beam.png b/docs/source/img/preprocessing_beams/right_propped_beam.png new file mode 100644 index 00000000..c7eb581b Binary files /dev/null and b/docs/source/img/preprocessing_beams/right_propped_beam.png differ diff --git a/docs/source/img/preprocessing_beams/simple_beam.png b/docs/source/img/preprocessing_beams/simple_beam.png new file mode 100644 index 00000000..48e34462 Binary files /dev/null and b/docs/source/img/preprocessing_beams/simple_beam.png differ diff --git a/docs/source/img/preprocessing_beams/three_span_beam.png b/docs/source/img/preprocessing_beams/three_span_beam.png new file mode 100644 index 00000000..39404ae5 Binary files /dev/null and b/docs/source/img/preprocessing_beams/three_span_beam.png differ diff --git a/docs/source/img/preprocessing_beams/two_span_beam.png b/docs/source/img/preprocessing_beams/two_span_beam.png new file mode 100644 index 00000000..22c18050 Binary files /dev/null and b/docs/source/img/preprocessing_beams/two_span_beam.png differ diff --git a/docs/source/img/preprocessing_trusses/attic_roof_truss.png b/docs/source/img/preprocessing_trusses/attic_roof_truss.png new file mode 100644 index 00000000..c0419d05 Binary files /dev/null and b/docs/source/img/preprocessing_trusses/attic_roof_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/double_fink_roof_truss.png b/docs/source/img/preprocessing_trusses/double_fink_roof_truss.png new file mode 100644 index 00000000..d26545c0 Binary files /dev/null and b/docs/source/img/preprocessing_trusses/double_fink_roof_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/double_howe_roof_truss.png b/docs/source/img/preprocessing_trusses/double_howe_roof_truss.png new file mode 100644 index 00000000..7d0e4b95 Binary files /dev/null and b/docs/source/img/preprocessing_trusses/double_howe_roof_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/fan_roof_truss.png b/docs/source/img/preprocessing_trusses/fan_roof_truss.png new file mode 100644 index 00000000..41ef7501 Binary files /dev/null and b/docs/source/img/preprocessing_trusses/fan_roof_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/fink_roof_truss.png b/docs/source/img/preprocessing_trusses/fink_roof_truss.png new file mode 100644 index 00000000..98ababa2 Binary files /dev/null and b/docs/source/img/preprocessing_trusses/fink_roof_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/howe_flat_truss.png b/docs/source/img/preprocessing_trusses/howe_flat_truss.png new file mode 100644 index 00000000..798f4e73 Binary files /dev/null and b/docs/source/img/preprocessing_trusses/howe_flat_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/howe_roof_truss.png b/docs/source/img/preprocessing_trusses/howe_roof_truss.png new file mode 100644 index 00000000..6337d661 Binary files /dev/null and b/docs/source/img/preprocessing_trusses/howe_roof_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/king_post_roof_truss.png b/docs/source/img/preprocessing_trusses/king_post_roof_truss.png new file mode 100644 index 00000000..01c0cf2c Binary files /dev/null and b/docs/source/img/preprocessing_trusses/king_post_roof_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/modified_fan_roof_truss.png b/docs/source/img/preprocessing_trusses/modified_fan_roof_truss.png new file mode 100644 index 00000000..c315727a Binary files /dev/null and b/docs/source/img/preprocessing_trusses/modified_fan_roof_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/modified_queen_post_roof_truss.png b/docs/source/img/preprocessing_trusses/modified_queen_post_roof_truss.png new file mode 100644 index 00000000..5db12e8a Binary files /dev/null and b/docs/source/img/preprocessing_trusses/modified_queen_post_roof_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/pratt_flat_truss.png b/docs/source/img/preprocessing_trusses/pratt_flat_truss.png new file mode 100644 index 00000000..a5fd87eb Binary files /dev/null and b/docs/source/img/preprocessing_trusses/pratt_flat_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/pratt_roof_truss.png b/docs/source/img/preprocessing_trusses/pratt_roof_truss.png new file mode 100644 index 00000000..10e40110 Binary files /dev/null and b/docs/source/img/preprocessing_trusses/pratt_roof_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/queen_post_roof_truss.png b/docs/source/img/preprocessing_trusses/queen_post_roof_truss.png new file mode 100644 index 00000000..d109d3bf Binary files /dev/null and b/docs/source/img/preprocessing_trusses/queen_post_roof_truss.png differ diff --git a/docs/source/img/preprocessing_trusses/warren_flat_truss.png b/docs/source/img/preprocessing_trusses/warren_flat_truss.png new file mode 100644 index 00000000..12d711ec Binary files /dev/null and b/docs/source/img/preprocessing_trusses/warren_flat_truss.png differ diff --git a/docs/source/index.rst b/docs/source/index.rst index 7aff50cf..91bf4a64 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -39,6 +39,10 @@ Indices and tables vertex + preprocessing_beams + + preprocessing_trusses + saving examples diff --git a/docs/source/preprocessing_beams.rst b/docs/source/preprocessing_beams.rst new file mode 100644 index 00000000..02a0790d --- /dev/null +++ b/docs/source/preprocessing_beams.rst @@ -0,0 +1,191 @@ +Preprocessing: Beams +==================== + +The :code:`anastruct.preprocess.beam` module provides ready-made beam generators that automatically +define nodes, elements, and supports for common structural configurations. Instead of building a +model element by element with :code:`SystemElements`, you instantiate a beam type and move straight +to loading and analysis. + +Importing +--------- + +The :code:`beam` module is available at the top level of the :code:`anastruct` package: + +.. code-block:: python + + from anastruct import beam + +You can also import individual classes or the factory function directly: + +.. code-block:: python + + from anastruct.preprocess.beam import SimpleBeam, MultiSpanBeam, create_beam + +Once instantiated, every beam exposes its underlying :code:`SystemElements` model as +:code:`beam.system`, giving access to the full suite of analysis and plotting methods. + +Section properties +------------------ + +All beam constructors accept an optional :code:`section` keyword — a dictionary describing +the cross-section. The only required key is :code:`EI` (bending stiffness); the remaining +keys are filled with defaults if omitted: + +- :code:`EI` — bending stiffness *(required; default* :code:`1e6` *)* +- :code:`EA` — axial stiffness *(optional; default* :code:`1e8` *)* +- :code:`g` — distributed self-weight per unit length *(optional; default* :code:`0.0` *)* + +.. code-block:: python + + # Bending stiffness only — EA and g use defaults + b = beam.SimpleBeam(length=6.0, section={"EI": 8000}) + + # Fully specified + b = beam.SimpleBeam(length=6.0, section={"EI": 8000, "EA": 1.5e9, "g": 2.5}) + +If :code:`section` is omitted entirely the beam uses the module defaults listed above. + +Single-span beams +----------------- + +Simple beam +########### + +.. autoclass:: anastruct.preprocess.beam.SimpleBeam + + .. automethod:: __init__ + +.. image:: img/preprocessing_beams/simple_beam.png + +Cantilever beams +################ + +.. autoclass:: anastruct.preprocess.beam.CantileverBeam + + .. automethod:: __init__ + +:code:`RightCantileverBeam` and :code:`LeftCantileverBeam` are convenience subclasses that +fix the free end without requiring a :code:`cantilever_side` argument. Each accepts +:code:`length`, :code:`angle`, and :code:`section` only: + +.. autoclass:: anastruct.preprocess.beam.RightCantileverBeam + +.. image:: img/preprocessing_beams/right_cantilever_beam.png + +.. autoclass:: anastruct.preprocess.beam.LeftCantileverBeam + +.. image:: img/preprocessing_beams/left_cantilever_beam.png + +Multi-span beams +---------------- + +Multi-span beams are simply supported, placing a pin at the first support and rollers at all +remaining supports. Spans may be equal or unequal. + +.. autoclass:: anastruct.preprocess.beam.MultiSpanBeam + + .. automethod:: __init__ + +.. image:: img/preprocessing_beams/multi_span_beam.png + +:code:`TwoSpanBeam`, :code:`ThreeSpanBeam`, and :code:`FourSpanBeam` are convenience wrappers +for equal-span multi-span beams. Each accepts :code:`length` (the total length, which is divided +equally), :code:`angle`, and :code:`section`: + +.. autoclass:: anastruct.preprocess.beam.TwoSpanBeam + +.. image:: img/preprocessing_beams/two_span_beam.png + +.. autoclass:: anastruct.preprocess.beam.ThreeSpanBeam + +.. image:: img/preprocessing_beams/three_span_beam.png + +.. autoclass:: anastruct.preprocess.beam.FourSpanBeam + +.. image:: img/preprocessing_beams/four_span_beam.png + +Propped beams +############# + +A propped beam has one interior simply supported span with a cantilever overhanging one end. + +.. autoclass:: anastruct.preprocess.beam.ProppedBeam + + .. automethod:: __init__ + +:code:`RightProppedBeam` and :code:`LeftProppedBeam` are convenience subclasses that fix +the overhang side. Each accepts :code:`interior_length`, :code:`cantilever_length`, +:code:`angle`, and :code:`section`: + +.. autoclass:: anastruct.preprocess.beam.RightProppedBeam + +.. image:: img/preprocessing_beams/right_propped_beam.png + +.. autoclass:: anastruct.preprocess.beam.LeftProppedBeam + +.. image:: img/preprocessing_beams/left_propped_beam.png + +Factory function +---------------- + +.. autofunction:: anastruct.preprocess.beam.create_beam + +Applying loads +-------------- + +All beam types share common load application methods. Loads can be applied across all spans at +once, or restricted to a specific span or list of spans by passing a span index (0-indexed) via +the :code:`spans` argument: + +.. automethod:: anastruct.preprocess.beam_class.Beam.apply_q_load_to_spans + +.. automethod:: anastruct.preprocess.beam_class.Beam.apply_point_load_to_spans + +You can also apply loads directly through the underlying :code:`SystemElements` object via +:code:`beam.system`, using the standard methods described in :doc:`loads`. + +Examples +-------- + +Simply supported beam with a uniform distributed load +##################################################### + +.. code-block:: python + :linenos: + + from anastruct import beam + + # 6 m simply supported beam — only EI needs to be specified + b = beam.SimpleBeam(length=6.0, section={"EI": 8000}) + + # 10 kN/m downward load across the full span + b.apply_q_load_to_spans(q=-10) + + b.system.solve() + b.show_structure() + b.system.show_bending_moment() + b.system.show_displacement() + +Three-span beam with a point load on one span +############################################# + +Spans can be unequal by supplying :code:`span_lengths` instead of :code:`length`. The +:code:`spans` argument of the load methods targets individual spans by their 0-based index. + +.. code-block:: python + :linenos: + + from anastruct import beam + + # Spans of 4 m, 6 m, and 4 m — indexed 0, 1, 2 + b = beam.MultiSpanBeam(span_lengths=[4.0, 6.0, 4.0], section={"EI": 12000}) + + # 50 kN concentrated load at the mid-point of the middle span (index 1) + b.apply_point_load_to_spans(Fy=-50, relative_location=0.5, spans=1) + + # 2 kN/m dead load on all spans + b.apply_q_load_to_spans(q=-2) + + b.system.solve() + b.system.show_bending_moment() + b.system.show_reaction_force() diff --git a/docs/source/preprocessing_trusses.rst b/docs/source/preprocessing_trusses.rst new file mode 100644 index 00000000..f564edc5 --- /dev/null +++ b/docs/source/preprocessing_trusses.rst @@ -0,0 +1,286 @@ +Preprocessing: Trusses +====================== + +The :code:`anastruct.preprocess.truss` module provides ready-made truss generators for common +structural truss configurations. Geometry — nodes, elements, and supports — is built automatically +from a small set of input parameters, so you can focus on loading and analysis rather than model +construction. + +Importing +--------- + +The :code:`truss` module is available at the top level of the :code:`anastruct` package: + +.. code-block:: python + + from anastruct import truss + +You can also import individual classes or the factory function directly: + +.. code-block:: python + + from anastruct.preprocess.truss import PrattFlatTruss, FinkRoofTruss, create_truss + +Once instantiated, every truss exposes its underlying :code:`SystemElements` model as +:code:`truss.system`. + +Section properties +------------------ + +Both flat and roof trusses accept separate section dictionaries for each structural component. +As with beams, only :code:`EI` is required — the other keys are filled with defaults if omitted. +Providing a partial dictionary (e.g. :code:`{"EI": 5000}`) merges with the defaults, so a single +key is always sufficient: + +- :code:`top_chord_section` — top chord *(default* :code:`{"EI": 1e6, "EA": 1e8, "g": 0.0}` *)* +- :code:`bottom_chord_section` — bottom chord *(same default)* +- :code:`web_section` — diagonal web members *(same default)* +- :code:`web_verticals_section` — vertical web members *(defaults to* :code:`web_section` *)* + +If all section arguments are omitted the truss uses the module defaults throughout. + +Flat trusses +------------ + +Flat trusses have parallel top and bottom chords divided into repeating panel bays. Three web +patterns are available: Howe (diagonals in compression under gravity), Pratt (diagonals in +tension under gravity), and Warren (diagonal-only web, no vertical members). + +All flat truss types require three parameters: :code:`width` (total span), :code:`height` +(depth between chords), and :code:`unit_width` (width of each panel bay). + +Howe flat truss +############### + +.. autoclass:: anastruct.preprocess.truss.HoweFlatTruss + +.. image:: img/preprocessing_trusses/howe_flat_truss.png + +Pratt flat truss +################ + +.. autoclass:: anastruct.preprocess.truss.PrattFlatTruss + +.. image:: img/preprocessing_trusses/pratt_flat_truss.png + +Warren flat truss +################# + +.. autoclass:: anastruct.preprocess.truss.WarrenFlatTruss + +.. image:: img/preprocessing_trusses/warren_flat_truss.png + +Roof trusses +------------ + +Roof trusses have sloped top chords meeting at a central peak. The truss height is computed +automatically from :code:`width` and :code:`roof_pitch_deg` — you do not specify it directly. +An optional eave overhang can be added with :code:`overhang_length`. + +The two required parameters are :code:`width` (total span) and :code:`roof_pitch_deg` (slope +angle in degrees, between 0 and 90). + +.. list-table:: + :widths: 35 15 50 + :header-rows: 1 + + * - Class + - Typical span + - Description + * - :code:`KingPostRoofTruss` + - up to ~8 m + - Single central vertical (king post); simplest pitched roof truss + * - :code:`QueenPostRoofTruss` + - 8–15 m + - Two verticals at quarter points with diagonal bracing + * - :code:`FinkRoofTruss` + - 10–20 m + - W-shaped web; most common general-purpose roof truss + * - :code:`HoweRoofTruss` + - 10–20 m + - Verticals with diagonals sloping toward the peak (compression) + * - :code:`PrattRoofTruss` + - 10–20 m + - Verticals with diagonals sloping away from the peak (tension) + * - :code:`FanRoofTruss` + - 15–25 m + - Diagonals radiating from lower chord panel points + * - :code:`ModifiedQueenPostRoofTruss` + - 12–20 m + - Enhanced Queen Post with additional web members + * - :code:`DoubleFinkRoofTruss` + - 20–30 m + - Two W patterns for long-span applications + * - :code:`DoubleHoweRoofTruss` + - 20–30 m + - Enhanced Howe for long spans or heavy loading + * - :code:`ModifiedFanRoofTruss` + - 20–30 m + - Enhanced Fan with additional web members + * - :code:`AtticRoofTruss` + - any + - Room-in-roof truss with habitable attic space + +King Post +######### + +.. autoclass:: anastruct.preprocess.truss.KingPostRoofTruss + +.. image:: img/preprocessing_trusses/king_post_roof_truss.png + +Queen Post +########## + +.. autoclass:: anastruct.preprocess.truss.QueenPostRoofTruss + +.. image:: img/preprocessing_trusses/queen_post_roof_truss.png + +Fink +#### + +.. autoclass:: anastruct.preprocess.truss.FinkRoofTruss + +.. image:: img/preprocessing_trusses/fink_roof_truss.png + +Howe +#### + +.. autoclass:: anastruct.preprocess.truss.HoweRoofTruss + +.. image:: img/preprocessing_trusses/howe_roof_truss.png + +Pratt +##### + +.. autoclass:: anastruct.preprocess.truss.PrattRoofTruss + +.. image:: img/preprocessing_trusses/pratt_roof_truss.png + +Fan +### + +.. autoclass:: anastruct.preprocess.truss.FanRoofTruss + +.. image:: img/preprocessing_trusses/fan_roof_truss.png + +Modified Queen Post +################### + +.. autoclass:: anastruct.preprocess.truss.ModifiedQueenPostRoofTruss + +.. image:: img/preprocessing_trusses/modified_queen_post_roof_truss.png + +Double Fink +########### + +.. autoclass:: anastruct.preprocess.truss.DoubleFinkRoofTruss + +.. image:: img/preprocessing_trusses/double_fink_roof_truss.png + +Double Howe +########### + +.. autoclass:: anastruct.preprocess.truss.DoubleHoweRoofTruss + +.. image:: img/preprocessing_trusses/double_howe_roof_truss.png + +Modified Fan +############ + +.. autoclass:: anastruct.preprocess.truss.ModifiedFanRoofTruss + +.. image:: img/preprocessing_trusses/modified_fan_roof_truss.png + +Attic (room-in-roof) truss +########################## + +The attic truss creates vertical walls and a flat ceiling to provide usable space beneath +the roof. It requires two additional parameters beyond the standard roof truss inputs: +:code:`attic_width` (interior floor width, which must be less than the total :code:`width`) +and an optional :code:`attic_height` (ceiling height; if omitted it defaults to the height +where the vertical walls naturally meet the sloped top chord). + +.. autoclass:: anastruct.preprocess.truss.AtticRoofTruss + + .. automethod:: __init__ + +.. image:: img/preprocessing_trusses/attic_roof_truss.png + +Factory function +---------------- + +.. autofunction:: anastruct.preprocess.truss.create_truss + +Applying loads +-------------- + +Distributed loads can be applied to the full top or bottom chord in a single call: + +.. automethod:: anastruct.preprocess.truss_class.Truss.apply_q_load_to_top_chord + +.. automethod:: anastruct.preprocess.truss_class.Truss.apply_q_load_to_bottom_chord + +For roof trusses the top chord is segmented into :code:`"left"` and :code:`"right"` slopes. +Passing :code:`chord_segment="left"` or :code:`chord_segment="right"` restricts the load to +one slope; omitting the argument applies it to both. + +Point loads and element-level loads can be applied through :code:`truss.system` using the +standard methods described in :doc:`loads`. + +Examples +-------- + +Pratt flat truss with a distributed roof load +############################################# + +.. code-block:: python + :linenos: + + from anastruct import truss + + # 18 m span, 2 m deep, 3 m panel bays + # Providing only EI for each chord — EA and g use defaults + t = truss.PrattFlatTruss( + width=18.0, + height=2.0, + unit_width=3.0, + top_chord_section={"EI": 5000}, + web_section={"EI": 100}, + ) + + # 10 kN/m downward load applied vertically along the top chord + t.apply_q_load_to_top_chord(q=-10, direction="y") + + t.system.solve() + t.show_structure() + t.system.show_axial_force() + t.system.show_displacement() + +Fink roof truss with wind load +############################## + +For roof trusses the peak height is derived from the pitch: a 30° pitch on a 12 m span gives +a peak at 6 × tan(30°) ≈ 3.46 m. Wind load is commonly applied perpendicular to the slope +using :code:`direction="element"`. + +.. code-block:: python + :linenos: + + from anastruct import truss + + # 12 m span at 30° pitch — height computed automatically + t = truss.FinkRoofTruss( + width=12.0, + roof_pitch_deg=30, + top_chord_section={"EI": 8000, "EA": 2e8}, + bottom_chord_section={"EI": 8000, "EA": 2e8}, + web_section={"EI": 100, "EA": 8e7}, + ) + + # 2 kN/m wind load perpendicular to each top-chord slope + t.apply_q_load_to_top_chord(q=-2, direction="element") + + t.system.solve() + t.show_structure() + t.system.show_bending_moment() + t.system.show_reaction_force() diff --git a/poetry.lock b/poetry.lock index 54803efd..16833a85 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,12 +1,13 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "alabaster" version = "1.0.0" description = "A light, configurable Sphinx theme" -optional = false +optional = true python-versions = ">=3.10" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, @@ -18,11 +19,12 @@ version = "4.0.1" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.10.0" -groups = ["test"] +groups = ["main", "test"] files = [ {file = "astroid-4.0.1-py3-none-any.whl", hash = "sha256:37ab2f107d14dc173412327febf6c78d39590fdafcb44868f03b6c03452e3db0"}, {file = "astroid-4.0.1.tar.gz", hash = "sha256:0d778ec0def05b935e198412e62f9bcca8b3b5c39fdbe50b0ba074005e477aab"}, ] +markers = {main = "extra == \"docs\""} [package.dependencies] typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} @@ -47,9 +49,10 @@ test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"] name = "babel" version = "2.17.0" description = "Internationalization utilities" -optional = false +optional = true python-versions = ">=3.8" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, @@ -116,9 +119,10 @@ uvloop = ["uvloop (>=0.15.2)"] name = "certifi" version = "2025.10.5" description = "Python package for providing Mozilla's CA Bundle." -optional = false +optional = true python-versions = ">=3.7" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, @@ -128,9 +132,10 @@ files = [ name = "charset-normalizer" version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false +optional = true python-versions = ">=3.7" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, @@ -268,12 +273,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["docs", "test"] +groups = ["main", "test"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {docs = "sys_platform == \"win32\"", test = "sys_platform == \"win32\" or platform_system == \"Windows\""} +markers = {main = "sys_platform == \"win32\" and extra == \"docs\"", test = "sys_platform == \"win32\" or platform_system == \"Windows\""} [[package]] name = "contourpy" @@ -282,7 +287,6 @@ description = "Python library for calculating contours of 2D quadrilateral grids optional = false python-versions = ">=3.10" groups = ["main", "dev"] -markers = "python_version == \"3.10\"" files = [ {file = "contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934"}, {file = "contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989"}, @@ -342,6 +346,7 @@ files = [ {file = "contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5"}, {file = "contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54"}, ] +markers = {main = "python_version == \"3.10\" and extra == \"plot\"", dev = "python_version == \"3.10\""} [package.dependencies] numpy = ">=1.23" @@ -360,7 +365,6 @@ description = "Python library for calculating contours of 2D quadrilateral grids optional = false python-versions = ">=3.11" groups = ["main", "dev"] -markers = "python_version >= \"3.11\"" files = [ {file = "contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1"}, {file = "contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381"}, @@ -435,6 +439,7 @@ files = [ {file = "contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77"}, {file = "contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880"}, ] +markers = {main = "python_version >= \"3.11\" and extra == \"plot\"", dev = "python_version >= \"3.11\""} [package.dependencies] numpy = ">=1.25" @@ -565,6 +570,7 @@ files = [ {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, ] +markers = {main = "extra == \"plot\""} [package.extras] docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] @@ -602,9 +608,10 @@ profile = ["gprof2dot (>=2022.7.29)"] name = "docutils" version = "0.21.2" description = "Docutils -- Python Documentation Utilities" -optional = false +optional = true python-versions = ">=3.9" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, @@ -703,6 +710,7 @@ files = [ {file = "fonttools-4.61.0-py3-none-any.whl", hash = "sha256:276f14c560e6f98d24ef7f5f44438e55ff5a67f78fa85236b218462c9f5d0635"}, {file = "fonttools-4.61.0.tar.gz", hash = "sha256:ec520a1f0c7758d7a858a00f090c1745f6cde6a7c5e76fb70ea4044a15f712e7"}, ] +markers = {main = "extra == \"plot\""} [package.extras] all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0) ; python_version <= \"3.14\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] @@ -721,9 +729,10 @@ woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "bro name = "idna" version = "3.15" description = "Internationalized Domain Names in Applications (IDNA)" -optional = false +optional = true python-versions = ">=3.8" -groups = ["dev", "docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"}, {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"}, @@ -736,9 +745,10 @@ all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, @@ -885,9 +895,10 @@ testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"] name = "jinja2" version = "3.1.6" description = "A very fast and expressive template engine." -optional = false +optional = true python-versions = ">=3.7" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -1009,14 +1020,16 @@ files = [ {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1"}, {file = "kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d"}, ] +markers = {main = "extra == \"plot\""} [[package]] name = "markupsafe" version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." -optional = false +optional = true python-versions = ">=3.9" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -1173,6 +1186,7 @@ files = [ {file = "matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91"}, {file = "matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7"}, ] +markers = {main = "extra == \"plot\""} [package.dependencies] contourpy = ">=1.0.1" @@ -1362,11 +1376,12 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "docs", "test"] +groups = ["main", "dev", "test"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] +markers = {main = "extra == \"plot\" or extra == \"docs\""} [[package]] name = "parso" @@ -1512,6 +1527,7 @@ files = [ {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, ] +markers = {main = "extra == \"plot\""} [package.extras] docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] @@ -1603,11 +1619,12 @@ version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" -groups = ["docs", "test"] +groups = ["main", "test"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] +markers = {main = "extra == \"docs\""} [package.extras] windows-terminal = ["colorama (>=0.4.6)"] @@ -1629,8 +1646,8 @@ astroid = ">=4.0.1,<=4.1.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, + {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.3.6", markers = "python_version == \"3.11\""}, ] isort = ">=5,<5.13 || >5.13,<8" mccabe = ">=0.6,<0.8" @@ -1653,6 +1670,7 @@ files = [ {file = "pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e"}, {file = "pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6"}, ] +markers = {main = "extra == \"plot\""} [package.extras] diagrams = ["jinja2", "railroad-diagrams"] @@ -1745,6 +1763,7 @@ files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] +markers = {main = "extra == \"plot\""} [package.dependencies] six = ">=1.5" @@ -1764,13 +1783,98 @@ files = [ [package.extras] dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"docs\"" +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + [[package]] name = "requests" version = "2.33.0" description = "Python HTTP for Humans." -optional = false +optional = true python-versions = ">=3.10" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, @@ -1862,14 +1966,16 @@ files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] +markers = {main = "extra == \"plot\""} [[package]] name = "snowballstemmer" version = "3.0.1" description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." -optional = false +optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, @@ -1879,9 +1985,10 @@ files = [ name = "sphinx" version = "8.1.3" description = "Python documentation generator" -optional = false +optional = true python-versions = ">=3.10" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2"}, {file = "sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927"}, @@ -1911,13 +2018,36 @@ docs = ["sphinxcontrib-websupport"] lint = ["flake8 (>=6.0)", "mypy (==1.11.1)", "pyright (==1.1.384)", "pytest (>=6.0)", "ruff (==0.6.9)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.18.0.20240506)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241005)", "types-requests (==2.32.0.20240914)", "types-urllib3 (==1.26.25.14)"] test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] +[[package]] +name = "sphinx-autoapi" +version = "3.6.0" +description = "Sphinx API documentation generator" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"docs\"" +files = [ + {file = "sphinx_autoapi-3.6.0-py3-none-any.whl", hash = "sha256:f3b66714493cab140b0e896d33ce7137654a16ac1edb6563edcbd47bf975f711"}, + {file = "sphinx_autoapi-3.6.0.tar.gz", hash = "sha256:c685f274e41d0842ae7e199460c322c4bd7fec816ccc2da8d806094b4f64af06"}, +] + +[package.dependencies] +astroid = [ + {version = ">=2.7", markers = "python_version < \"3.12\""}, + {version = ">=3", markers = "python_version >= \"3.12\""}, +] +Jinja2 = "*" +PyYAML = "*" +sphinx = ">=7.4.0" + [[package]] name = "sphinx-autodoc-typehints" version = "3.0.1" description = "Type hints (PEP 484) support for the Sphinx autodoc extension" -optional = false +optional = true python-versions = ">=3.10" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinx_autodoc_typehints-3.0.1-py3-none-any.whl", hash = "sha256:4b64b676a14b5b79cefb6628a6dc8070e320d4963e8ff640a2f3e9390ae9045a"}, {file = "sphinx_autodoc_typehints-3.0.1.tar.gz", hash = "sha256:b9b40dd15dee54f6f810c924f863f9cf1c54f9f3265c495140ea01be7f44fa55"}, @@ -1930,13 +2060,35 @@ sphinx = ">=8.1.3" docs = ["furo (>=2024.8.6)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "defusedxml (>=0.7.1)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "sphobjinv (>=2.3.1.2)", "typing-extensions (>=4.12.2)"] +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +description = "Read the Docs theme for Sphinx" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"docs\"" +files = [ + {file = "sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89"}, + {file = "sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c"}, +] + +[package.dependencies] +docutils = ">0.18,<0.23" +sphinx = ">=6,<10" +sphinxcontrib-jquery = ">=4,<5" + +[package.extras] +dev = ["bump2version", "transifex-client", "twine", "wheel"] + [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" -optional = false +optional = true python-versions = ">=3.9" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, @@ -1951,9 +2103,10 @@ test = ["pytest"] name = "sphinxcontrib-devhelp" version = "2.0.0" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" -optional = false +optional = true python-versions = ">=3.9" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, @@ -1968,9 +2121,10 @@ test = ["pytest"] name = "sphinxcontrib-htmlhelp" version = "2.1.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -optional = false +optional = true python-versions = ">=3.9" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, @@ -1981,13 +2135,30 @@ lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["html5lib", "pytest"] +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +description = "Extension to include jQuery on newer Sphinx releases" +optional = true +python-versions = ">=2.7" +groups = ["main"] +markers = "extra == \"docs\"" +files = [ + {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, + {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, +] + +[package.dependencies] +Sphinx = ">=1.8" + [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" -optional = false +optional = true python-versions = ">=3.5" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, @@ -2000,9 +2171,10 @@ test = ["flake8", "mypy", "pytest"] name = "sphinxcontrib-qthelp" version = "2.0.0" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" -optional = false +optional = true python-versions = ">=3.9" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, @@ -2017,9 +2189,10 @@ test = ["defusedxml (>=0.7.1)", "pytest"] name = "sphinxcontrib-serializinghtml" version = "2.0.0" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" -optional = false +optional = true python-versions = ">=3.9" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, @@ -2068,8 +2241,7 @@ version = "2.3.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["docs", "test"] -markers = "python_version == \"3.10\"" +groups = ["main", "test"] files = [ {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, @@ -2114,6 +2286,7 @@ files = [ {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, ] +markers = {main = "extra == \"docs\" and python_version == \"3.10\"", test = "python_version == \"3.10\""} [[package]] name = "tomlkit" @@ -2149,19 +2322,21 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["test"] +groups = ["main", "test"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +markers = {main = "extra == \"docs\" and python_version == \"3.10\""} [[package]] name = "urllib3" version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false +optional = true python-versions = ">=3.10" -groups = ["docs"] +groups = ["main"] +markers = "extra == \"docs\"" files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, @@ -2186,9 +2361,10 @@ files = [ ] [extras] +docs = ["sphinx", "sphinx-autoapi", "sphinx-autodoc-typehints", "sphinx-rtd-theme"] plot = ["matplotlib"] [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "87a9ed1ffb1f3f518a442609a884b7d48039a9c5ea7b1a310e3d5431b15999fe" +content-hash = "8f394b3ef9613f8640270c3afc07a72b275f81d6d8d929596c473f8577fe6549" diff --git a/pyproject.toml b/pyproject.toml index d6e721d5..dae71e54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "anastruct" -version = "1.6.2" +version = "1.7.0" authors = ["Ritchie Vink "] maintainers = ["Brooks Smith "] description = "Finite element analysis of 2D structures" diff --git a/tests/test_beam.py b/tests/test_beam.py new file mode 100644 index 00000000..792ad884 --- /dev/null +++ b/tests/test_beam.py @@ -0,0 +1,674 @@ +"""Tests for beam generator functionality. + +Tests cover: +- Unit tests for each beam type (geometry, supports, connectivity) +- Integration tests (load application and solve) +- Factory function +- Validation method +- Edge cases and error handling +""" + +import warnings + +import numpy as np +from pytest import approx, raises + +from anastruct.preprocess.beam import ( + CantileverBeam, + FourSpanBeam, + LeftCantileverBeam, + LeftProppedBeam, + MultiSpanBeam, + ProppedBeam, + RightCantileverBeam, + RightProppedBeam, + SimpleBeam, + ThreeSpanBeam, + TwoSpanBeam, + create_beam, +) +from anastruct.vertex import Vertex + + +def describe_simple_beam(): + def it_creates_valid_geometry(): + beam = SimpleBeam(length=10) + + assert beam.type == "Simple Beam" + assert beam.length == 10 + assert len(beam.nodes) == 2 + assert beam.validate() + + def it_has_correct_supports(): + beam = SimpleBeam(length=10) + + assert beam.support_definitions[1] == "pinned" + assert beam.support_definitions[2] == "roller" + assert len(beam.support_definitions) == 2 + + def it_has_one_span(): + beam = SimpleBeam(length=10) + + assert len(beam.node_ids) == 1 + assert beam.node_ids[0] == [1, 2] + assert len(beam.element_ids) == 1 + assert len(beam.element_ids[0]) == 1 + + def it_has_correct_node_positions(): + beam = SimpleBeam(length=10) + + assert beam.nodes[0].x == approx(0.0) + assert beam.nodes[0].y == approx(0.0) + assert beam.nodes[1].x == approx(10.0) + assert beam.nodes[1].y == approx(0.0) + + def it_creates_system_elements(): + beam = SimpleBeam(length=10) + + assert len(beam.system.element_map) == 1 + + def it_validates_positive_length(): + with raises(ValueError, match="must be positive"): + SimpleBeam(length=-5) + + with raises(ValueError, match="must be positive"): + SimpleBeam(length=0) + + +def describe_cantilever_beam(): + def it_creates_valid_geometry(): + beam = CantileverBeam(length=5) + + assert beam.type == "Cantilever Beam" + assert beam.length == 5 + assert len(beam.nodes) == 2 + assert beam.validate() + + def it_defaults_to_right_cantilever(): + beam = CantileverBeam(length=5) + + # Default cantilever_side="right" means free end on the right, fixed on left + assert beam.cantilever_side == "right" + assert beam.support_definitions[1] == "fixed" + assert len(beam.support_definitions) == 1 + + def it_supports_left_cantilever(): + beam = CantileverBeam(length=5, cantilever_side="left") + + # Free end on the left, fixed on right + assert beam.cantilever_side == "left" + assert beam.support_definitions[2] == "fixed" + assert len(beam.support_definitions) == 1 + + def it_validates_cantilever_side(): + with raises(ValueError, match="cantilever_side"): + CantileverBeam(length=5, cantilever_side="middle") + + +def describe_right_cantilever_beam(): + def it_creates_valid_geometry(): + beam = RightCantileverBeam(length=5) + + assert beam.type == "Right Cantilever Beam" + assert beam.cantilever_side == "right" + assert beam.validate() + + def it_has_fixed_support_on_left(): + beam = RightCantileverBeam(length=5) + + assert beam.support_definitions[1] == "fixed" + assert 2 not in beam.support_definitions + + +def describe_left_cantilever_beam(): + def it_creates_valid_geometry(): + beam = LeftCantileverBeam(length=5) + + assert beam.type == "Left Cantilever Beam" + assert beam.cantilever_side == "left" + assert beam.validate() + + def it_has_fixed_support_on_right(): + beam = LeftCantileverBeam(length=5) + + assert beam.support_definitions[2] == "fixed" + assert 1 not in beam.support_definitions + + +def describe_multi_span_beam(): + def it_creates_with_num_spans(): + beam = MultiSpanBeam(length=30, num_spans=3) + + assert beam.type == "Multi-Span Beam" + assert beam.length == approx(30) + assert len(beam.nodes) == 4 + assert len(beam.node_ids) == 3 + assert beam.validate() + + def it_creates_with_span_lengths(): + beam = MultiSpanBeam(span_lengths=[4, 6, 5]) + + assert beam.length == approx(15) + assert len(beam.nodes) == 4 + assert len(beam.node_ids) == 3 + + def it_has_supports_at_all_interior_and_end_nodes(): + beam = MultiSpanBeam(length=30, num_spans=3) + + # Node 1 = pinned, nodes 2, 3, 4 = roller + assert beam.support_definitions[1] == "pinned" + assert beam.support_definitions[2] == "roller" + assert beam.support_definitions[3] == "roller" + assert beam.support_definitions[4] == "roller" + assert len(beam.support_definitions) == 4 + + def it_supports_left_cantilever(): + beam = MultiSpanBeam(span_lengths=[3, 5, 5], cantilevers="left") + + # Left cantilever: first span is unsupported on the left + assert 1 not in beam.support_definitions + assert beam.support_definitions[2] == "pinned" + assert beam.support_definitions[3] == "roller" + assert beam.support_definitions[4] == "roller" + + def it_supports_right_cantilever(): + beam = MultiSpanBeam(span_lengths=[5, 5, 3], cantilevers="right") + + # Right cantilever: last span is unsupported on the right + assert beam.support_definitions[1] == "pinned" + assert beam.support_definitions[2] == "roller" + assert 4 not in beam.support_definitions + + def it_supports_both_cantilevers(): + beam = MultiSpanBeam(span_lengths=[3, 5, 5, 3], cantilevers="both") + + # Both cantilevers: first and last spans unsupported at ends + assert 1 not in beam.support_definitions + assert beam.support_definitions[2] == "pinned" + assert beam.support_definitions[3] == "roller" + assert 5 not in beam.support_definitions + + def it_has_one_element_per_span(): + beam = MultiSpanBeam(length=30, num_spans=3) + + for span in range(3): + assert len(beam.element_ids[span]) == 1 + + def it_validates_input_combinations(): + with raises(ValueError, match="Either num_spans or span_lengths"): + MultiSpanBeam() + + with raises(ValueError, match="Only one of"): + MultiSpanBeam(span_lengths=[5, 5], num_spans=2) + + with raises(ValueError, match="length must also be provided"): + MultiSpanBeam(num_spans=3) + + def it_validates_cantilevers_parameter(): + with raises(ValueError, match="cantilevers must be"): + MultiSpanBeam(length=20, num_spans=2, cantilevers="top") + + def it_creates_equal_spans_from_num_spans(): + beam = MultiSpanBeam(length=30, num_spans=3) + + assert beam.span_lengths == [approx(10), approx(10), approx(10)] + + +def describe_two_span_beam(): + def it_creates_valid_geometry(): + beam = TwoSpanBeam(length=20) + + assert beam.type == "Two-Span Beam" + assert beam.length == approx(20) + assert len(beam.nodes) == 3 + assert len(beam.node_ids) == 2 + assert beam.validate() + + def it_has_correct_supports(): + beam = TwoSpanBeam(length=20) + + assert beam.support_definitions[1] == "pinned" + assert beam.support_definitions[2] == "roller" + assert beam.support_definitions[3] == "roller" + + +def describe_three_span_beam(): + def it_creates_valid_geometry(): + beam = ThreeSpanBeam(length=30) + + assert beam.type == "Three-Span Beam" + assert beam.length == approx(30) + assert len(beam.nodes) == 4 + assert len(beam.node_ids) == 3 + assert beam.validate() + + def it_has_support_at_every_node(): + beam = ThreeSpanBeam(length=30) + + assert len(beam.support_definitions) == 4 + assert beam.support_definitions[1] == "pinned" + for i in range(2, 5): + assert beam.support_definitions[i] == "roller" + + +def describe_four_span_beam(): + def it_creates_valid_geometry(): + beam = FourSpanBeam(length=40) + + assert beam.type == "Four-Span Beam" + assert beam.length == approx(40) + assert len(beam.nodes) == 5 + assert len(beam.node_ids) == 4 + assert beam.validate() + + def it_has_support_at_every_node(): + beam = FourSpanBeam(length=40) + + assert len(beam.support_definitions) == 5 + + +def describe_propped_beam(): + def it_creates_right_propped(): + beam = ProppedBeam( + interior_length=8, cantilever_length=3, cantilever_side="right" + ) + + assert beam.type == "Propped Beam" + assert beam.length == approx(11) + assert beam.cantilever_side == "right" + assert beam.validate() + + def it_creates_left_propped(): + beam = ProppedBeam( + interior_length=8, cantilever_length=3, cantilever_side="left" + ) + + assert beam.length == approx(11) + assert beam.cantilever_side == "left" + assert beam.validate() + + def it_has_supports_on_interior_span_only(): + beam = ProppedBeam( + interior_length=8, cantilever_length=3, cantilever_side="right" + ) + + # Right cantilever: support at nodes 1 and 2, not at node 3 + assert beam.support_definitions[1] == "pinned" + assert beam.support_definitions[2] == "roller" + assert 3 not in beam.support_definitions + + def it_validates_cantilever_side(): + with raises(ValueError, match="cantilever_side"): + ProppedBeam(interior_length=8, cantilever_length=3, cantilever_side="up") + + +def describe_right_propped_beam(): + def it_creates_valid_geometry(): + beam = RightProppedBeam(interior_length=8, cantilever_length=3) + + assert beam.type == "Right Propped Beam" + assert beam.cantilever_side == "right" + assert beam.validate() + + def it_has_no_support_at_right_end(): + beam = RightProppedBeam(interior_length=8, cantilever_length=3) + + last_node = len(beam.nodes) + assert last_node not in beam.support_definitions + + +def describe_left_propped_beam(): + def it_creates_valid_geometry(): + beam = LeftProppedBeam(interior_length=8, cantilever_length=3) + + assert beam.type == "Left Propped Beam" + assert beam.cantilever_side == "left" + assert beam.validate() + + def it_has_no_support_at_left_end(): + beam = LeftProppedBeam(interior_length=8, cantilever_length=3) + + assert 1 not in beam.support_definitions + + +def describe_factory_function(): + """Tests for create_beam factory function.""" + + def it_creates_beam_by_name(): + beam = create_beam("simple", length=10) + + assert isinstance(beam, SimpleBeam) + assert beam.type == "Simple Beam" + + def it_handles_case_insensitive_names(): + beams = [ + create_beam("simple", length=10), + create_beam("SIMPLE", length=10), + create_beam("Simple", length=10), + ] + + for beam in beams: + assert isinstance(beam, SimpleBeam) + + def it_handles_different_name_separators(): + beam_underscore = create_beam("two_span", length=20) + beam_hyphen = create_beam("two-span", length=20) + beam_space = create_beam("two span", length=20) + + assert isinstance(beam_underscore, TwoSpanBeam) + assert isinstance(beam_hyphen, TwoSpanBeam) + assert isinstance(beam_space, TwoSpanBeam) + + def it_creates_all_beam_types(): + beams = { + "simple": {"length": 10}, + "cantilever": {"length": 5}, + "right_cantilever": {"length": 5}, + "left_cantilever": {"length": 5}, + "multi_span": {"length": 20, "num_spans": 2}, + "two_span": {"length": 20}, + "three_span": {"length": 30}, + "four_span": {"length": 40}, + "propped": {"interior_length": 8, "cantilever_length": 3}, + "right_propped": {"interior_length": 8, "cantilever_length": 3}, + "left_propped": {"interior_length": 8, "cantilever_length": 3}, + } + + for name, kwargs in beams.items(): + beam = create_beam(name, **kwargs) + assert beam.validate() + + def it_raises_error_for_invalid_type(): + with raises(ValueError, match="Unknown beam type"): + create_beam("nonexistent", length=10) + + def it_provides_helpful_error_with_available_types(): + with raises(ValueError, match="Available types"): + create_beam("invalid_type", length=10) + + +def describe_validate_method(): + def it_validates_correct_geometry(): + beam = SimpleBeam(length=10) + assert beam.validate() + + def it_catches_invalid_node_ids(): + beam = SimpleBeam(length=10) + + # Inject an invalid node ID + beam.node_ids[0] = [1, 99] + + with raises(ValueError, match="invalid node ID"): + beam.validate() + + def it_catches_duplicate_nodes(): + beam = SimpleBeam(length=10) + + # Make both nodes at the same position + beam.nodes[1] = Vertex(0.0, 0.0) + + with raises(ValueError, match="Duplicate nodes"): + beam.validate() + + +def describe_integration_tests(): + """Integration tests with load application.""" + + def describe_distributed_loads(): + def it_applies_q_load_to_simple_beam(): + beam = SimpleBeam(length=10) + beam.apply_q_load_to_spans(q=-5, direction="y") + + # Verify load was applied to the element + assert beam.system.loads_q is not None + assert len(beam.system.loads_q) > 0 + + def it_applies_q_load_to_specific_span(): + beam = TwoSpanBeam(length=20) + # Only load span 0 + beam.apply_q_load_to_spans(q=-10, direction="y", spans=0) + + # Only the element in span 0 should have a load + loaded_element_id = beam.element_ids[0][0] + assert loaded_element_id in beam.system.loads_q + + def it_applies_q_load_to_multiple_spans(): + beam = ThreeSpanBeam(length=30) + beam.apply_q_load_to_spans(q=-5, direction="y", spans=[0, 2]) + + # Elements in spans 0 and 2 should be loaded + for span in [0, 2]: + el_id = beam.element_ids[span][0] + assert el_id in beam.system.loads_q + + def it_applies_q_load_to_all_spans_by_default(): + beam = ThreeSpanBeam(length=30) + beam.apply_q_load_to_spans(q=-5, direction="y") + + # All elements should be loaded + for span in range(3): + el_id = beam.element_ids[span][0] + assert el_id in beam.system.loads_q + + def describe_point_loads(): + def it_applies_point_load_at_existing_node(): + beam = SimpleBeam(length=10) + # Load at start node (absolute_location=0 is at node 1) + beam.apply_point_load_to_spans(Fy=-100, absolute_location=0.0) + + assert len(beam.system.loads_point) > 0 + + def it_inserts_node_for_point_load(): + beam = SimpleBeam(length=10) + original_elements = len(beam.system.element_map) + + beam.apply_point_load_to_spans(Fy=-100, absolute_location=3.0) + + # Should have split the element, creating 2 elements from 1 + assert len(beam.system.element_map) == original_elements + 1 + + def it_updates_internal_ids_after_node_insertion(): + beam = SimpleBeam(length=10) + + beam.apply_point_load_to_spans(Fy=-100, absolute_location=3.0) + + # node_ids for span 0 should now have 3 nodes + assert len(beam.node_ids[0]) == 3 + # element_ids for span 0 should now have 2 elements + assert len(beam.element_ids[0]) == 2 + + def it_validates_location_arguments(): + beam = SimpleBeam(length=10) + + with raises( + ValueError, match="Either absolute_location or relative_location" + ): + beam.apply_point_load_to_spans(Fy=-100) + + with raises(ValueError, match="Only one of"): + beam.apply_point_load_to_spans( + Fy=-100, absolute_location=5.0, relative_location=0.5 + ) + + def it_applies_load_at_relative_location(): + beam = SimpleBeam(length=10) + + beam.apply_point_load_to_spans(Fy=-100, relative_location=0.5) + + # Should have inserted a node at midpoint and applied load + assert len(beam.node_ids[0]) == 3 + assert len(beam.system.loads_point) > 0 + + def describe_cantilever_loads(): + def it_applies_tip_load_to_cantilever(): + beam = RightCantileverBeam(length=5) + beam.apply_point_load_to_spans(Fy=-100, relative_location=1.0) + + # Load should be applied at the tip node + assert len(beam.system.loads_point) > 0 + + def it_applies_distributed_load_to_cantilever(): + beam = LeftCantileverBeam(length=8) + beam.apply_q_load_to_spans(q=-10, direction="y") + + assert len(beam.system.loads_q) > 0 + + def describe_solve_integration_tests(): + """Solve-based integration tests to verify reactions.""" + + def it_solves_simple_beam_with_udl(): + # Simple beam with UDL: q=-5, direction="y" on length=10 + # Reactions at each end = |q|*L/2 = 5*10/2 = 25 (upward) + beam = SimpleBeam(length=10) + beam.apply_q_load_to_spans(q=-5, direction="y") + beam.system.solve() + + # Node IDs are 1-based: left node=1, right node=2 + left_reaction = beam.system.get_node_results_system(node_id=1)["Fy"] + right_reaction = beam.system.get_node_results_system(node_id=2)["Fy"] + + # get_node_results_system returns reaction in system sign convention + assert abs(left_reaction) == approx(25, abs=1e-6) + assert abs(right_reaction) == approx(25, abs=1e-6) + + def it_solves_two_span_beam_with_udl(): + # Two-span beam with UDL: verify solve works for indeterminate structure + beam = TwoSpanBeam(length=20) + beam.apply_q_load_to_spans(q=-10, direction="y") + beam.system.solve() + + # Total load = 10 * 20 = 200, sum of reactions must equal 200 + total_reaction = sum( + abs(beam.system.get_node_results_system(node_id=i)["Fy"]) + for i in range(1, 4) + ) + assert total_reaction == approx(200, abs=1e-3) + + def it_solves_right_cantilever_with_tip_load(): + # RightCantileverBeam length=5, Fy=-100 at tip + # Fixed end reaction magnitude = 100 + beam = RightCantileverBeam(length=5) + beam.apply_point_load_to_spans(Fy=-100, relative_location=1.0) + beam.system.solve() + + # Fixed support is at left node (node_id=1) + fixed_reaction = beam.system.get_node_results_system(node_id=1)["Fy"] + + assert abs(fixed_reaction) == approx(100, abs=1e-6) + + def it_solves_left_cantilever_with_udl(): + # LeftCantileverBeam length=8, q=-10 + # Fixed end reaction magnitude = 80 + beam = LeftCantileverBeam(length=8) + beam.apply_q_load_to_spans(q=-10, direction="y") + beam.system.solve() + + # Fixed support is at right node (node_id=2) + fixed_reaction = beam.system.get_node_results_system(node_id=2)["Fy"] + + assert abs(fixed_reaction) == approx(80, abs=1e-6) + + +def describe_span_element_ids(): + """Tests for get_element_ids_of_spans.""" + + def it_gets_all_elements_when_none(): + beam = ThreeSpanBeam(length=30) + + all_ids = beam.get_element_ids_of_spans(spans=None) + assert len(all_ids) == 3 + + def it_gets_single_span(): + beam = ThreeSpanBeam(length=30) + + span_ids = beam.get_element_ids_of_spans(spans=1) + assert len(span_ids) == 1 + + def it_gets_multiple_spans(): + beam = ThreeSpanBeam(length=30) + + span_ids = beam.get_element_ids_of_spans(spans=[0, 2]) + assert len(span_ids) == 2 + + def it_raises_for_invalid_span(): + beam = ThreeSpanBeam(length=30) + + with raises(KeyError, match="span number"): + beam.get_element_ids_of_spans(spans=99) + + +def describe_angled_beams(): + """Tests for beams at non-zero angles.""" + + def it_creates_angled_simple_beam(): + beam = SimpleBeam(length=10, angle=45) + + expected_x = 10 * np.cos(np.radians(45)) + expected_y = 10 * np.sin(np.radians(45)) + assert beam.nodes[1].x == approx(expected_x) + assert beam.nodes[1].y == approx(expected_y) + assert beam.validate() + + def it_creates_vertical_beam(): + beam = SimpleBeam(length=10, angle=90) + + assert beam.nodes[1].x == approx(0.0, abs=1e-10) + assert beam.nodes[1].y == approx(10.0) + + def it_warns_for_radian_like_angle(): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + SimpleBeam(length=10, angle=1.5) + + assert len(w) == 1 + assert "degrees, not radians" in str(w[0].message) + + def it_does_not_warn_for_zero_angle(): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + SimpleBeam(length=10, angle=0) + + assert len(w) == 0 + + def it_does_not_warn_for_normal_angle(): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + SimpleBeam(length=10, angle=45) + + assert len(w) == 0 + + def it_normalizes_negative_angle(): + beam = SimpleBeam(length=10, angle=-90) + assert beam.angle == approx(270) + + +def describe_edge_cases(): + def it_creates_multiple_independent_instances(): + beam1 = SimpleBeam(length=10) + beam2 = SimpleBeam(length=20) + + # Instances should be independent + assert beam1.length != beam2.length + assert len(beam1.nodes) == len(beam2.nodes) + assert beam1.nodes[1].x != beam2.nodes[1].x + + def it_handles_very_short_beam(): + beam = SimpleBeam(length=0.001) + assert beam.validate() + + def it_handles_very_long_beam(): + beam = SimpleBeam(length=1e6) + assert beam.validate() + + def it_handles_unequal_span_lengths(): + beam = MultiSpanBeam(span_lengths=[3, 7, 5, 2]) + + assert beam.length == approx(17) + assert len(beam.nodes) == 5 + assert beam.validate() + + def it_handles_custom_section(): + section = {"EI": 2e6, "EA": 3e8, "g": 5.0} + beam = SimpleBeam(length=10, section=section) + + assert beam.section == section diff --git a/tests/test_truss.py b/tests/test_truss.py index 3137e16d..3472648d 100644 --- a/tests/test_truss.py +++ b/tests/test_truss.py @@ -184,12 +184,13 @@ def it_has_center_vertical_to_peak(): # Get the center vertical connection center_vertical = truss.web_verticals_node_pairs[0] - # Node 1 is center bottom, node 4 is peak - assert center_vertical == (1, 4) + # Node 2 is center bottom, node 5 is peak (1-based IDs) + assert center_vertical == (2, 5) # Verify these nodes are actually center bottom and peak - center_bottom = truss.nodes[1] - peak = truss.nodes[4] + # Node IDs are 1-based, so we access nodes list with index-1 + center_bottom = truss.nodes[1] # Node ID 2 at index 1 + peak = truss.nodes[4] # Node ID 5 at index 4 assert center_bottom.x == approx(truss.width / 2) assert center_bottom.y == approx(0) @@ -486,10 +487,11 @@ def it_catches_invalid_node_ids_in_connectivity(): def it_catches_duplicate_nodes(): truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) - # Add duplicate node at same location as node 0 + # Add duplicate node at same location as node ID 1 (index 0) original = truss.nodes[0] truss.nodes.append(Vertex(original.x, original.y)) - truss.web_node_pairs.append((0, len(truss.nodes) - 1)) + # Node IDs are 1-based, so new node ID is len(truss.nodes) + truss.web_node_pairs.append((1, len(truss.nodes))) with raises(ValueError, match="Duplicate nodes"): truss.validate() @@ -667,3 +669,88 @@ def it_creates_multiple_independent_instances(): # Should not affect the other assert len(truss1.nodes) != len(truss2.nodes) assert truss2.validate() + + +def describe_solve_tests(): + """Solve tests - verify trusses can be solved and produce valid reactions.""" + + def describe_king_post_roof_truss_solve(): + def it_solves_with_top_chord_load(): + truss = KingPostRoofTruss(width=10, roof_pitch_deg=30) + + # Apply distributed load to top chord + truss.apply_q_load_to_top_chord(q=-5, direction="y") + + # Solve the system + truss.system.solve() + + # Get support node IDs (1-based) + support_node_ids = list(truss.support_definitions.keys()) + assert len(support_node_ids) == 2 + + # Check that reactions are non-zero at support nodes + for node_id in support_node_ids: + reaction = truss.system.get_node_results_system(node_id=node_id)["Fy"] + assert ( + abs(reaction) > 0 + ), f"Node {node_id} should have non-zero reaction" + + # Verify total vertical reaction equals applied load + total_reaction = sum( + truss.system.get_node_results_system(node_id=node_id)["Fy"] + for node_id in support_node_ids + ) + + # Applied load = q * length of top chord + # For king post, top chord has 2 segments from ends to peak + top_chord_length = 0 + top_chord_el_ids = truss.get_element_ids_of_chord("top") + for el_id in top_chord_el_ids: + element = truss.system.element_map[el_id] + top_chord_length += element.l + + expected_total_load = -5 * top_chord_length + + assert total_reaction == approx( + expected_total_load, abs=0.1 + ), "Total reactions should equal total applied load" + + def describe_howe_flat_truss_solve(): + def it_solves_with_bottom_chord_load(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Apply distributed load to bottom chord + truss.apply_q_load_to_bottom_chord(q=-10, direction="y") + + # Solve the system + truss.system.solve() + + # Get support node IDs (1-based) + support_node_ids = list(truss.support_definitions.keys()) + assert len(support_node_ids) == 2 + + # Check that reactions are non-zero at support nodes + for node_id in support_node_ids: + reaction = truss.system.get_node_results_system(node_id=node_id)["Fy"] + assert ( + abs(reaction) > 0 + ), f"Node {node_id} should have non-zero reaction" + + # Verify total vertical reaction equals applied load + total_reaction = sum( + truss.system.get_node_results_system(node_id=node_id)["Fy"] + for node_id in support_node_ids + ) + + # Applied load = q * length of bottom chord + bottom_chord_length = 0 + bottom_chord_el_ids = truss.get_element_ids_of_chord("bottom") + for el_id in bottom_chord_el_ids: + element = truss.system.element_map[el_id] + bottom_chord_length += element.l + + expected_total_load = -10 * bottom_chord_length + + assert total_reaction == approx( + expected_total_load, abs=0.1 + ), "Total reactions should equal total applied load"