From 3eb55b4c3511379fe5fc047b0cce7c8b8ca7e389 Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:19:53 +1100 Subject: [PATCH 01/23] Add new abstract truss class --- anastruct/truss/truss_class.py | 179 +++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 anastruct/truss/truss_class.py diff --git a/anastruct/truss/truss_class.py b/anastruct/truss/truss_class.py new file mode 100644 index 00000000..9bc36018 --- /dev/null +++ b/anastruct/truss/truss_class.py @@ -0,0 +1,179 @@ +from abc import ABC, abstractmethod +from typing import Literal, Optional, Sequence, Union + +from anastruct import SystemElements +from anastruct.fem.system_components.util import add_node +from anastruct.types import LoadDirection, SectionProps, Vertex + +DEFAULT_TRUSS_SECTION: SectionProps = { + "EI": 1e6, + "EA": 1e8, + "g": 0.0, +} + + +class Truss(ABC): + # Common geometry + width: float + height: float + + # Material properties + top_chord_section: SectionProps + bottom_chord_section: SectionProps + web_section: SectionProps + web_verticals_section: SectionProps + + # Configuraion + top_chord_continous: bool + bottom_chord_continuous: bool + supports_type: Literal["simple", "pinned", "fixed"] + + # Defined by subclass + nodes: list[Vertex] = [] + top_chord_node_ids: list[int] = [] + bottom_chord_node_ids: list[int] = [] + web_node_pairs: list[tuple[int, int]] = [] + web_verticals_node_pairs: list[tuple[int, int]] = [] + support_definitions: dict[int, Literal["fixed", "pinned", "roller"]] = {} + top_chord_length: float = 0.0 + bottom_chord_length: float = 0.0 + + # System + system: SystemElements + + def __init__( + self, + width: float, + height: float, + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + top_chord_continous: bool = True, + bottom_chord_continuous: bool = True, + supports_type: Literal["simple", "pinned", "fixed"] = "simple", + ): + self.width = width + self.height = height + self.top_chord_section = top_chord_section or DEFAULT_TRUSS_SECTION + self.bottom_chord_section = bottom_chord_section or DEFAULT_TRUSS_SECTION + self.web_section = web_section or DEFAULT_TRUSS_SECTION + self.web_verticals_section = web_verticals_section or self.web_section + self.top_chord_continous = top_chord_continous + self.bottom_chord_continuous = bottom_chord_continuous + self.supports_type = supports_type + + self.define_nodes() + self.define_connectivity() + self.define_supports() + + self.system = SystemElements() + self.add_nodes() + self.add_elements() + self.add_supports() + + @property + @abstractmethod + def type(self) -> str: + pass + + @abstractmethod + def define_nodes(self) -> None: + pass + + @abstractmethod + def define_connectivity(self) -> None: + pass + + @abstractmethod + def define_supports(self) -> None: + pass + + def add_nodes(self) -> None: + for i, vertex in enumerate(self.nodes): + add_node(self.system, point=vertex, node_id=i) + + def add_elements(self) -> None: + # Bottom chord elements + for i, j in zip( + self.bottom_chord_node_ids[:-1], self.bottom_chord_node_ids[1:] + ): + self.system.add_element( + location=(self.nodes[i], self.nodes[j]), + EA=self.bottom_chord_section["EA"], + EI=self.bottom_chord_section["EI"], + g=self.bottom_chord_section["g"], + spring=None if self.bottom_chord_continuous else {1: 0.0, 2: 0.0}, + ) + + # Top chord elements + for i, j in zip(self.top_chord_node_ids[:-1], self.top_chord_node_ids[1:]): + self.system.add_element( + location=(self.nodes[i], self.nodes[j]), + EA=self.top_chord_section["EA"], + EI=self.top_chord_section["EI"], + g=self.top_chord_section["g"], + spring=None if self.top_chord_continous else {1: 0.0, 2: 0.0}, + ) + + # Web diagonal elements + for i, j in self.web_node_pairs: + self.system.add_element( + location=(self.nodes[i], self.nodes[j]), + EA=self.web_section["EA"], + EI=self.web_section["EI"], + g=self.web_section["g"], + spring={1: 0.0, 2: 0.0}, + ) + + # Web vertical elements + for i, j in self.web_verticals_node_pairs: + self.system.add_element( + location=(self.nodes[i], self.nodes[j]), + EA=self.web_verticals_section["EA"], + EI=self.web_verticals_section["EI"], + g=self.web_verticals_section["g"], + spring={1: 0.0, 2: 0.0}, + ) + + def add_supports(self) -> None: + 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 apply_q_loads_to_top_chord( + 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, + ) -> None: + pass + + def apply_q_loads_to_bottom_chord( + self, + x_start: float, + x_end: float, + 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, + ) -> None: + pass + + def apply_point_load_to_top_chord( + self, x_loc: float, Fx: float = 0.0, Fy: float = 0.0, rotation: float = 0.0 + ) -> None: + pass + + def apply_point_load_to_bottom_chord( + self, x_loc: float, Fx: float = 0.0, Fy: float = 0.0, rotation: float = 0.0 + ) -> None: + pass + + def show_structure(self) -> None: + self.system.show_structure() From c5e7c89e01d4e760ada52fd3c2724f84be082e4e Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:20:14 +1100 Subject: [PATCH 02/23] Create add_node() function to ensure control over node ids --- anastruct/fem/system_components/util.py | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/anastruct/fem/system_components/util.py b/anastruct/fem/system_components/util.py index 55a5b433..dd2ff17b 100644 --- a/anastruct/fem/system_components/util.py +++ b/anastruct/fem/system_components/util.py @@ -203,6 +203,46 @@ def det_node_ids( return node_ids[0], node_ids[1] +def add_node( + system: "SystemElements", point: Vertex, node_id: Optional[int] = None +) -> int: + """Add a node, optionally with a specific ID, without adding an element + + Args: + system (SystemElements): System in which the nodes are located + point (Vertex): Location of the node + node_id (Optional[int], optional): node_id to assign to the node. Defaults to None, which means to use the first available node_id automatically. + + Raises: + FEMException: Raised when the location is already assigned to a different node id. + FEMException: Raised when the node id is already assigned to a different location. + + Returns: + int: The node id of the added (or existing) node + """ + if point in system._vertices: + if node_id is not None: + existing_node_id = system._vertices[point] + if existing_node_id != node_id: + raise FEMException( + "Flawed inputs", + f"Location {point} is already assigned to node id {existing_node_id}, cannot assign to node id {node_id}.", + ) + return existing_node_id + + if node_id is None: + node_id = max(system.node_map.keys(), default=0) + 1 + elif node_id in system.node_map and system.node_map[node_id].vertex != point: + raise FEMException( + "Flawed inputs", + f"Node id {node_id} is already assigned to a different location.", + ) + + system._vertices[point] = node_id + system.node_map[node_id] = Node(node_id, vertex=point) + return node_id + + def support_check(system: "SystemElements", node_id: int) -> None: """Check if the node is a hinge From a4a7d6e1f0675c577bdf97c1c8533f166569aebc Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:20:49 +1100 Subject: [PATCH 03/23] Fix bug-ish in negative index; do not negate an index of '0' --- anastruct/fem/system.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/anastruct/fem/system.py b/anastruct/fem/system.py index 29160686..2184693c 100644 --- a/anastruct/fem/system.py +++ b/anastruct/fem/system.py @@ -2363,6 +2363,6 @@ def _negative_index_to_id(idx: int, collection: Collection[int]) -> int: idx = int(idx) else: raise TypeError("Node or element id must be an integer") - if idx > 0: + if idx >= 0: return idx return max(collection) + (idx + 1) From 7cf7044d75fda5692b38756d5e72b74008e83d1c Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:22:37 +1100 Subject: [PATCH 04/23] Add SectionProps type, always import Vertex --- anastruct/types.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/anastruct/types.py b/anastruct/types.py index 22c9d90f..4a57b24f 100644 --- a/anastruct/types.py +++ b/anastruct/types.py @@ -1,9 +1,8 @@ -from typing import TYPE_CHECKING, Dict, Literal, Sequence, Union +from typing import TYPE_CHECKING, Dict, Literal, Sequence, TypedDict, Union import numpy as np -if TYPE_CHECKING: - from anastruct.vertex import Vertex +from anastruct.vertex import Vertex AxisNumber = Literal[1, 2, 3] Dimension = Literal["x", "y", "y_neg", "both"] @@ -15,3 +14,12 @@ Spring = Dict[Literal[1, 2], float] SupportDirection = Literal["x", "y", "1", "2", 1, 2] VertexLike = Union[Sequence[Union[float, int]], np.ndarray, "Vertex"] + +SectionProps = TypedDict( + "SectionProps", + { + "EI": float, + "EA": float, + "g": float, + }, +) From 1fd7dd3bd00ca425b55b42860dbb9ecf6b16193f Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:23:27 +1100 Subject: [PATCH 05/23] Add a Howe truss --- anastruct/__init__.py | 1 + anastruct/truss/truss.py | 161 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 anastruct/truss/truss.py diff --git a/anastruct/__init__.py b/anastruct/__init__.py index 320502d6..6b59dbca 100644 --- a/anastruct/__init__.py +++ b/anastruct/__init__.py @@ -1,3 +1,4 @@ from anastruct.fem.system import SystemElements from anastruct.fem.util.load import LoadCase, LoadCombination +from anastruct.truss import truss from anastruct.vertex import Vertex diff --git a/anastruct/truss/truss.py b/anastruct/truss/truss.py new file mode 100644 index 00000000..d8d63468 --- /dev/null +++ b/anastruct/truss/truss.py @@ -0,0 +1,161 @@ +from typing import Literal, Optional + +import numpy as np + +from anastruct.truss.truss_class import DEFAULT_TRUSS_SECTION, Truss +from anastruct.types import SectionProps, Vertex + + +class HoweTruss(Truss): + # Data types specific to this truss type + EndType = Literal["flat", "triangle_down", "triangle_up"] + SupportLoc = Literal["bottom_chord", "top_chord", "both"] + + # Additional geometry for this truss type + unit_width: float + end_type: EndType + supports_loc: SupportLoc + + # Addtional configuration + min_end_fraction: float + enforce_even_units: bool + + # Computed properties + n_units: int + end_width: float + + @property + def type(self) -> str: + return "Howe" + + def __init__( + self, + width: float, + height: float, + unit_width: float, + end_type: EndType = "triangle_down", + supports_loc: SupportLoc = "bottom_chord", + min_end_fraction: float = 0.5, + enforce_even_units: bool = True, + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + ): + + self.unit_width = unit_width + self.end_type = end_type + self.supports_loc = supports_loc + self.min_end_fraction = min_end_fraction + self.enforce_even_units = enforce_even_units + self.n_units = np.floor( + (width - unit_width * 2 * min_end_fraction) / unit_width + ) + if self.enforce_even_units and self.n_units % 2 != 0: + self.n_units -= 1 + self.end_width = (width - self.n_units * unit_width) / 2 + super().__init__( + width, + height, + top_chord_section, + bottom_chord_section, + web_section, + web_verticals_section, + ) + + def define_nodes(self) -> None: + # Bottom chord nodes + if self.end_type != "triangle_up": + self.nodes.append(Vertex(0.0, 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_up": + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + if self.end_type != "triangle_down": + self.nodes.append(Vertex(0, 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_down": + self.nodes.append(Vertex(self.width, self.height)) + + def define_connectivity(self) -> None: + n_bottom_nodes = ( + int(self.n_units) + 1 + (2 if self.end_type != "triangle_up" else 0) + ) + n_top_nodes = ( + int(self.n_units) + 1 + (2 if self.end_type != "triangle_down" else 0) + ) + + # Bottom chord connectivity + self.bottom_chord_node_ids = list(range(0, n_bottom_nodes)) + + # Top chord connectivity + self.top_chord_node_ids = list( + range(n_bottom_nodes, n_bottom_nodes + n_top_nodes) + ) + + # Web diagonals connectivity + start_bot = 0 + start_top = 0 + end_bot = None # a None index means go to the end + 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) + ) + start_top = 2 + end_top = -2 + elif self.end_type == "flat": + start_top = 1 + end_top = -1 + mid_bot = len(self.bottom_chord_node_ids) // 2 + mid_top = len(self.top_chord_node_ids) // 2 + for b, t in zip( + self.bottom_chord_node_ids[start_bot : mid_bot + 1], + self.top_chord_node_ids[start_top : mid_top + 1], + ): + self.web_node_pairs.append((b, t)) + for b, t in zip( + self.bottom_chord_node_ids[end_bot : mid_bot - 1 : -1], + self.top_chord_node_ids[end_top : mid_top - 1 : -1], + ): + self.web_node_pairs.append((b, t)) + + # Web verticals connectivity + start_bot = 0 + start_top = 0 + end_bot = None + end_top = None + if self.end_type == "triangle_up": + start_top = 1 + end_top = -1 + elif self.end_type == "triangle_down": + start_bot = 1 + end_bot = -1 + for b, t in zip( + self.bottom_chord_node_ids[start_bot:end_bot], + self.top_chord_node_ids[start_top:end_top], + ): + self.web_verticals_node_pairs.append((b, t)) + + def define_supports(self) -> None: + if self.supports_loc in ["bottom_chord", "both"]: + self.support_definitions[0] = ( + self.supports_type if self.supports_type != "simple" else "pinned" + ) + self.support_definitions[max(self.bottom_chord_node_ids)] = ( + self.supports_type if self.supports_type != "simple" else "roller" + ) + if self.supports_loc in ["top_chord", "both"]: + self.support_definitions[min(self.top_chord_node_ids)] = ( + self.supports_type if self.supports_type != "simple" else "pinned" + ) + self.support_definitions[max(self.top_chord_node_ids)] = ( + self.supports_type if self.supports_type != "simple" else "roller" + ) From bf138eadc48c81f921212cd4a8c97fd65233be8b Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Mon, 17 Nov 2025 18:01:17 +1100 Subject: [PATCH 06/23] Add a generic RoofTruss class --- anastruct/truss/truss_class.py | 128 ++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 35 deletions(-) diff --git a/anastruct/truss/truss_class.py b/anastruct/truss/truss_class.py index 9bc36018..467b2a54 100644 --- a/anastruct/truss/truss_class.py +++ b/anastruct/truss/truss_class.py @@ -1,5 +1,7 @@ from abc import ABC, abstractmethod -from typing import Literal, Optional, Sequence, Union +from typing import Iterable, Literal, Optional, Sequence, Union + +import numpy as np from anastruct import SystemElements from anastruct.fem.system_components.util import add_node @@ -30,8 +32,8 @@ class Truss(ABC): # Defined by subclass nodes: list[Vertex] = [] - top_chord_node_ids: list[int] = [] - bottom_chord_node_ids: list[int] = [] + top_chord_node_ids: list[list[int]] = [] + bottom_chord_node_ids: list[list[int]] = [] web_node_pairs: list[tuple[int, int]] = [] web_verticals_node_pairs: list[tuple[int, int]] = [] support_definitions: dict[int, Literal["fixed", "pinned", "roller"]] = {} @@ -94,47 +96,49 @@ def add_nodes(self) -> None: add_node(self.system, point=vertex, node_id=i) def add_elements(self) -> None: + def add_segment_elements( + node_pairs: Iterable[tuple[int, int]], + section: SectionProps, + continuous: bool, + ) -> None: + for i, j in node_pairs: + self.system.add_element( + location=(self.nodes[i], self.nodes[j]), + EA=section["EA"], + EI=section["EI"], + g=section["g"], + spring=None if continuous else {1: 0.0, 2: 0.0}, + ) + # Bottom chord elements - for i, j in zip( - self.bottom_chord_node_ids[:-1], self.bottom_chord_node_ids[1:] - ): - self.system.add_element( - location=(self.nodes[i], self.nodes[j]), - EA=self.bottom_chord_section["EA"], - EI=self.bottom_chord_section["EI"], - g=self.bottom_chord_section["g"], - spring=None if self.bottom_chord_continuous else {1: 0.0, 2: 0.0}, + for segment_node_ids in self.bottom_chord_node_ids: + add_segment_elements( + node_pairs=zip(segment_node_ids[:-1], segment_node_ids[1:]), + section=self.bottom_chord_section, + continuous=self.bottom_chord_continuous, ) # Top chord elements - for i, j in zip(self.top_chord_node_ids[:-1], self.top_chord_node_ids[1:]): - self.system.add_element( - location=(self.nodes[i], self.nodes[j]), - EA=self.top_chord_section["EA"], - EI=self.top_chord_section["EI"], - g=self.top_chord_section["g"], - spring=None if self.top_chord_continous else {1: 0.0, 2: 0.0}, + for segment_node_ids in self.top_chord_node_ids: + add_segment_elements( + node_pairs=zip(segment_node_ids[:-1], segment_node_ids[1:]), + section=self.top_chord_section, + continuous=self.top_chord_continous, ) # Web diagonal elements - for i, j in self.web_node_pairs: - self.system.add_element( - location=(self.nodes[i], self.nodes[j]), - EA=self.web_section["EA"], - EI=self.web_section["EI"], - g=self.web_section["g"], - spring={1: 0.0, 2: 0.0}, - ) + add_segment_elements( + node_pairs=self.web_node_pairs, + section=self.web_section, + continuous=False, + ) # Web vertical elements - for i, j in self.web_verticals_node_pairs: - self.system.add_element( - location=(self.nodes[i], self.nodes[j]), - EA=self.web_verticals_section["EA"], - EI=self.web_verticals_section["EI"], - g=self.web_verticals_section["g"], - spring={1: 0.0, 2: 0.0}, - ) + add_segment_elements( + node_pairs=self.web_verticals_node_pairs, + section=self.web_verticals_section, + continuous=False, + ) def add_supports(self) -> None: for node_id, support_type in self.support_definitions.items(): @@ -177,3 +181,57 @@ def apply_point_load_to_bottom_chord( def show_structure(self) -> None: self.system.show_structure() + + +class RoofTruss(Truss): + # Additional geometry for this truss type + overhang_length: float + roof_pitch_deg: float + + # Computed properties + roof_pitch: float + + @property + def type(self) -> str: + return "[Generic] Roof Truss" + + def __init__( + self, + width: float, + roof_pitch_deg: float, + overhang_length: float = 0.0, + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + ): + self.roof_pitch_deg = roof_pitch_deg + self.roof_pitch = np.radians(roof_pitch_deg) + height = (width / 2) * np.tan(self.roof_pitch) + self.overhang_length = overhang_length + super().__init__( + width, + height, + top_chord_section, + bottom_chord_section, + web_section, + web_verticals_section, + ) + + @abstractmethod + def define_nodes(self) -> None: + pass + + @abstractmethod + def define_connectivity(self) -> None: + pass + + def define_supports(self) -> None: + bottom_left = 0 + bottom_right = max(self.bottom_chord_node_ids[0]) + self.support_definitions[bottom_left] = ( + self.supports_type if self.supports_type != "simple" else "pinned" + ) + self.support_definitions[bottom_right] = ( + self.supports_type if self.supports_type != "simple" else "roller" + ) From 087e98452b66fcaf47dc84f35e541868e4711d46 Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Mon, 17 Nov 2025 18:01:27 +1100 Subject: [PATCH 07/23] Add a slew of roof truss types --- anastruct/truss/truss.py | 656 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 638 insertions(+), 18 deletions(-) diff --git a/anastruct/truss/truss.py b/anastruct/truss/truss.py index d8d63468..ac3eeac0 100644 --- a/anastruct/truss/truss.py +++ b/anastruct/truss/truss.py @@ -2,11 +2,11 @@ import numpy as np -from anastruct.truss.truss_class import DEFAULT_TRUSS_SECTION, Truss +from anastruct.truss.truss_class import DEFAULT_TRUSS_SECTION, RoofTruss, Truss from anastruct.types import SectionProps, Vertex -class HoweTruss(Truss): +class HoweFlatTruss(Truss): # Data types specific to this truss type EndType = Literal["flat", "triangle_down", "triangle_up"] SupportLoc = Literal["bottom_chord", "top_chord", "both"] @@ -26,7 +26,7 @@ class HoweTruss(Truss): @property def type(self) -> str: - return "Howe" + return "Howe Flat Truss" def __init__( self, @@ -91,12 +91,12 @@ 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(0, n_bottom_nodes))] # Top chord connectivity - self.top_chord_node_ids = list( - range(n_bottom_nodes, n_bottom_nodes + n_top_nodes) - ) + self.top_chord_node_ids = [ + list(range(n_bottom_nodes, n_bottom_nodes + n_top_nodes)) + ] # Web diagonals connectivity start_bot = 0 @@ -110,20 +110,178 @@ def define_connectivity(self) -> None: (n_bottom_nodes - 1, n_bottom_nodes + n_top_nodes - 1) ) start_top = 2 - end_top = -2 + end_top = -3 elif self.end_type == "flat": + start_top = 1 + end_top = -2 + mid_bot = len(self.bottom_chord_node_ids) // 2 + mid_top = len(self.top_chord_node_ids) // 2 + for b, t in zip( + self.bottom_chord_node_ids[0][start_bot : mid_bot + 1], + self.top_chord_node_ids[0][start_top : mid_top + 1], + ): + self.web_node_pairs.append((b, t)) + for b, t in zip( + self.bottom_chord_node_ids[0][end_bot : mid_bot - 1 : -1], + self.top_chord_node_ids[0][end_top : mid_top - 1 : -1], + ): + self.web_node_pairs.append((b, t)) + + # Web verticals connectivity + start_bot = 0 + start_top = 0 + end_bot = None + end_top = None + if self.end_type == "triangle_up": start_top = 1 end_top = -1 + elif self.end_type == "triangle_down": + start_bot = 1 + end_bot = -1 + for b, t in zip( + self.bottom_chord_node_ids[0][start_bot:end_bot], + self.top_chord_node_ids[0][start_top:end_top], + ): + self.web_verticals_node_pairs.append((b, t)) + + def define_supports(self) -> None: + bottom_left = 0 + bottom_right = max(self.bottom_chord_node_ids[0]) + top_left = min(self.top_chord_node_ids[0]) + top_right = max(self.top_chord_node_ids[0]) + if self.supports_loc in ["bottom_chord", "both"]: + self.support_definitions[bottom_left] = ( + self.supports_type if self.supports_type != "simple" else "pinned" + ) + self.support_definitions[bottom_right] = ( + self.supports_type if self.supports_type != "simple" else "roller" + ) + if self.supports_loc in ["top_chord", "both"]: + self.support_definitions[top_left] = ( + self.supports_type if self.supports_type != "simple" else "pinned" + ) + self.support_definitions[top_right] = ( + self.supports_type if self.supports_type != "simple" else "roller" + ) + + +class PrattFlatTruss(Truss): + # Data types specific to this truss type + EndType = Literal["flat", "triangle_down", "triangle_up"] + SupportLoc = Literal["bottom_chord", "top_chord", "both"] + + # Additional geometry for this truss type + unit_width: float + end_type: EndType + supports_loc: SupportLoc + + # Addtional configuration + min_end_fraction: float + enforce_even_units: bool + + # Computed properties + n_units: int + end_width: float + + @property + def type(self) -> str: + return "Pratt Flat Truss" + + def __init__( + self, + width: float, + height: float, + unit_width: float, + end_type: EndType = "triangle_down", + supports_loc: SupportLoc = "bottom_chord", + min_end_fraction: float = 0.5, + enforce_even_units: bool = True, + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + ): + self.unit_width = unit_width + self.end_type = end_type + self.supports_loc = supports_loc + self.min_end_fraction = min_end_fraction + self.enforce_even_units = enforce_even_units + self.n_units = np.floor( + (width - unit_width * 2 * min_end_fraction) / unit_width + ) + if self.enforce_even_units and self.n_units % 2 != 0: + self.n_units -= 1 + self.end_width = (width - self.n_units * unit_width) / 2 + super().__init__( + width, + height, + top_chord_section, + bottom_chord_section, + web_section, + web_verticals_section, + ) + + def define_nodes(self) -> None: + # Bottom chord nodes + if self.end_type != "triangle_up": + self.nodes.append(Vertex(0.0, 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_up": + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + if self.end_type != "triangle_down": + self.nodes.append(Vertex(0, 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_down": + self.nodes.append(Vertex(self.width, self.height)) + + def define_connectivity(self) -> None: + n_bottom_nodes = ( + int(self.n_units) + 1 + (2 if self.end_type != "triangle_up" else 0) + ) + n_top_nodes = ( + int(self.n_units) + 1 + (2 if self.end_type != "triangle_down" else 0) + ) + + # Bottom chord connectivity + self.bottom_chord_node_ids = [list(range(0, n_bottom_nodes))] + + # Top chord connectivity + self.top_chord_node_ids = [ + list(range(n_bottom_nodes, n_bottom_nodes + n_top_nodes)) + ] + + # Web diagonals connectivity + start_bot = 0 + start_top = 0 + end_bot = None # a None index means go to the end + 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) + ) + start_bot = 2 + end_bot = -3 + elif self.end_type == "flat": + start_bot = 1 + end_bot = -2 mid_bot = len(self.bottom_chord_node_ids) // 2 mid_top = len(self.top_chord_node_ids) // 2 for b, t in zip( - self.bottom_chord_node_ids[start_bot : mid_bot + 1], - self.top_chord_node_ids[start_top : mid_top + 1], + self.bottom_chord_node_ids[0][start_bot : mid_bot + 1], + self.top_chord_node_ids[0][start_top : mid_top + 1], ): self.web_node_pairs.append((b, t)) for b, t in zip( - self.bottom_chord_node_ids[end_bot : mid_bot - 1 : -1], - self.top_chord_node_ids[end_top : mid_top - 1 : -1], + self.bottom_chord_node_ids[0][end_bot : mid_bot - 1 : -1], + self.top_chord_node_ids[0][end_top : mid_top - 1 : -1], ): self.web_node_pairs.append((b, t)) @@ -139,23 +297,485 @@ def define_connectivity(self) -> None: start_bot = 1 end_bot = -1 for b, t in zip( - self.bottom_chord_node_ids[start_bot:end_bot], - self.top_chord_node_ids[start_top:end_top], + self.bottom_chord_node_ids[0][start_bot:end_bot], + self.top_chord_node_ids[0][start_top:end_top], ): self.web_verticals_node_pairs.append((b, t)) def define_supports(self) -> None: + bottom_left = 0 + bottom_right = max(self.bottom_chord_node_ids[0]) + top_left = min(self.top_chord_node_ids[0]) + top_right = max(self.top_chord_node_ids[0]) if self.supports_loc in ["bottom_chord", "both"]: - self.support_definitions[0] = ( + self.support_definitions[bottom_left] = ( self.supports_type if self.supports_type != "simple" else "pinned" ) - self.support_definitions[max(self.bottom_chord_node_ids)] = ( + self.support_definitions[bottom_right] = ( self.supports_type if self.supports_type != "simple" else "roller" ) if self.supports_loc in ["top_chord", "both"]: - self.support_definitions[min(self.top_chord_node_ids)] = ( + self.support_definitions[top_left] = ( self.supports_type if self.supports_type != "simple" else "pinned" ) - self.support_definitions[max(self.top_chord_node_ids)] = ( + self.support_definitions[top_right] = ( self.supports_type if self.supports_type != "simple" else "roller" ) + + +class WarrenFlatTruss(Truss): + # Data types specific to this truss type + EndType = Literal["triangle_down", "triangle_up"] + SupportLoc = Literal["bottom_chord", "top_chord", "both"] + + # Additional geometry for this truss type + unit_width: float + end_type: EndType + supports_loc: SupportLoc + + # Computed properties + n_units: int + end_width: float + + @property + def type(self) -> str: + return "Warren Flat Truss" + + def __init__( + self, + width: float, + height: float, + unit_width: float, + end_type: EndType = "triangle_down", + supports_loc: SupportLoc = "bottom_chord", + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + ): + self.unit_width = unit_width + self.end_type = end_type + self.supports_loc = supports_loc + # Note that the maths for a Warren truss is simpler than for Howe/Pratt, because there + # cannot be any option for non-even number of units, and there are no special cases for + # web verticals. + self.n_units = np.floor(width / unit_width) + if self.n_units % 2 != 0: + self.n_units -= 1 + self.end_width = (width - self.n_units * unit_width) / 2 + (unit_width / 2) + super().__init__( + width, + height, + top_chord_section, + bottom_chord_section, + web_section, + web_verticals_section, + ) + + def define_nodes(self) -> None: + # Bottom chord nodes + if self.end_type == "triangle_down": + 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": + self.nodes.append(Vertex(self.width, 0.0)) + else: + self.nodes.append( + Vertex(self.width - (self.end_width - self.unit_width / 2), 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": + self.nodes.append(Vertex(self.width, self.height)) + else: + self.nodes.append( + Vertex(self.width - (self.end_width - self.unit_width / 2), self.height) + ) + + def define_connectivity(self) -> None: + n_bottom_nodes = int(self.n_units) + ( + 1 if self.end_type == "triangle_down" else 0 + ) + n_top_nodes = int(self.n_units) + (1 if self.end_type == "triangle_up" else 0) + + # Bottom chord connectivity + self.bottom_chord_node_ids = [list(range(0, n_bottom_nodes))] + + # Top chord connectivity + self.top_chord_node_ids = [ + list(range(n_bottom_nodes, n_bottom_nodes + n_top_nodes)) + ] + + # Web diagonals connectivity + # sloping up from bottom left to top right + top_start = 0 if self.end_type == "triangle_down" else 1 + for b, t in zip( + self.bottom_chord_node_ids[0], + self.top_chord_node_ids[0][top_start:], + ): + self.web_node_pairs.append((b, t)) + # sloping down from top left to bottom right + bot_start = 0 if self.end_type == "triangle_up" else 1 + for b, t in zip( + self.top_chord_node_ids[0], + self.bottom_chord_node_ids[0][bot_start:], + ): + self.web_node_pairs.append((b, t)) + + def define_supports(self) -> None: + bottom_left = 0 + bottom_right = max(self.bottom_chord_node_ids[0]) + top_left = min(self.top_chord_node_ids[0]) + top_right = max(self.top_chord_node_ids[0]) + if self.supports_loc in ["bottom_chord", "both"]: + self.support_definitions[bottom_left] = ( + self.supports_type if self.supports_type != "simple" else "pinned" + ) + self.support_definitions[bottom_right] = ( + self.supports_type if self.supports_type != "simple" else "roller" + ) + if self.supports_loc in ["top_chord", "both"]: + self.support_definitions[top_left] = ( + self.supports_type if self.supports_type != "simple" else "pinned" + ) + self.support_definitions[top_right] = ( + self.supports_type if self.supports_type != "simple" else "roller" + ) + + +class KingPostRoofTruss(RoofTruss): + @property + def type(self) -> str: + return "King Post Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 2, self.height)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [[0, 1, 2]] + left_v = 0 + right_v = 2 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [[left_v, 3], [3, right_v]] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 4) # left overhang + self.top_chord_node_ids[1].append(5) # right overhang + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 3)) # center vertical + + +class QueenPostRoofTruss(RoofTruss): + @property + def type(self) -> str: + return "Queen Post Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 4, self.height / 2)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [[0, 1, 2]] + left_v = 0 + right_v = 2 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [[left_v, 3, 4], [4, 5, right_v]] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 6) # left overhang + self.top_chord_node_ids[1].append(7) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 3)) # left diagonal + self.web_node_pairs.append((1, 5)) # right diagonal + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 3)) # center vertical + + +class FinkRoofTruss(RoofTruss): + @property + def type(self) -> str: + return "Fink Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 3, 0.0)) + self.nodes.append(Vertex(2 * self.width / 3, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 4, self.height / 2)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [[0, 1, 2, 3]] + left_v = 0 + right_v = 3 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [[left_v, 4, 5], [5, 6, right_v]] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 7) # left overhang + self.top_chord_node_ids[1].append(8) # 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)) + + +class HoweRoofTruss(RoofTruss): + @property + def type(self) -> str: + return "Howe Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(3 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 4, self.height / 2)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [[0, 1, 2, 3, 4]] + left_v = 0 + right_v = 2 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [[left_v, 5, 6], [6, 7, right_v]] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 8) # left overhang + self.top_chord_node_ids[1].append(9) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((2, 5)) # left diagonal + self.web_node_pairs.append((2, 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 + + +class PrattRoofTruss(RoofTruss): + @property + def type(self) -> str: + return "Pratt Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(3 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 4, self.height / 2)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [[0, 1, 2, 3, 4]] + left_v = 0 + right_v = 2 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [[left_v, 5, 6], [6, 7, right_v]] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 8) # left overhang + self.top_chord_node_ids[1].append(9) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 6)) # left diagonal + self.web_node_pairs.append((3, 6)) # 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 + + +class FanRoofTruss(RoofTruss): + @property + def type(self) -> str: + return "Fan Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 3, 0.0)) + self.nodes.append(Vertex(2 * self.width / 3, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 6, self.height / 3)) + self.nodes.append(Vertex(2 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(4 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(5 * self.width / 6, self.height / 3)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [[0, 1, 2, 3]] + left_v = 0 + right_v = 3 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [[left_v, 4, 5, 6], [6, 7, 8, right_v]] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 9) # left overhang + self.top_chord_node_ids[1].append(10) # 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)) + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 5)) + self.web_verticals_node_pairs.append((2, 7)) + + +__all__ = [ + "HoweFlatTruss", + "PrattFlatTruss", + "WarrenFlatTruss", + "KingPostRoofTruss", + "QueenPostRoofTruss", + "FinkRoofTruss", + "HoweRoofTruss", + "PrattRoofTruss", + "FanRoofTruss", +] From b4324dea6962da64e313959f11448f5f979c8067 Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:54:50 +1100 Subject: [PATCH 08/23] Create an upper-level FlatTruss base class too --- anastruct/truss/truss_class.py | 88 ++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/anastruct/truss/truss_class.py b/anastruct/truss/truss_class.py index 467b2a54..f8b24bf3 100644 --- a/anastruct/truss/truss_class.py +++ b/anastruct/truss/truss_class.py @@ -183,6 +183,93 @@ def show_structure(self) -> None: self.system.show_structure() +class FlatTruss(Truss): + # Data types specific to this truss type + EndType = Literal["flat", "triangle_down", "triangle_up"] + SupportLoc = Literal["bottom_chord", "top_chord", "both"] + + # Additional geometry for this truss type + unit_width: float + end_type: EndType + supports_loc: SupportLoc + + # Addtional configuration + min_end_fraction: float + enforce_even_units: bool + + # Computed properties + n_units: int + end_width: float + + @property + @abstractmethod + def type(self) -> str: + return "[Generic] Flat Truss" + + def __init__( + self, + width: float, + height: float, + unit_width: float, + end_type: EndType = "triangle_down", + supports_loc: SupportLoc = "bottom_chord", + min_end_fraction: float = 0.5, + enforce_even_units: bool = True, + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + ): + + self.unit_width = unit_width + self.end_type = end_type + self.supports_loc = supports_loc + self.min_end_fraction = min_end_fraction + self.enforce_even_units = enforce_even_units + self.n_units = np.floor( + (width - unit_width * 2 * min_end_fraction) / unit_width + ) + if self.enforce_even_units and self.n_units % 2 != 0: + self.n_units -= 1 + self.end_width = (width - self.n_units * unit_width) / 2 + super().__init__( + width, + height, + top_chord_section, + bottom_chord_section, + web_section, + web_verticals_section, + ) + + @abstractmethod + def define_nodes(self) -> None: + pass + + @abstractmethod + def define_connectivity(self) -> None: + pass + + def define_supports(self) -> None: + bottom_left = 0 + bottom_right = max(self.bottom_chord_node_ids[0]) + top_left = min(self.top_chord_node_ids[0]) + top_right = max(self.top_chord_node_ids[0]) + if self.supports_loc in ["bottom_chord", "both"]: + self.support_definitions[bottom_left] = ( + self.supports_type if self.supports_type != "simple" else "pinned" + ) + self.support_definitions[bottom_right] = ( + self.supports_type if self.supports_type != "simple" else "roller" + ) + if self.supports_loc in ["top_chord", "both"]: + self.support_definitions[top_left] = ( + self.supports_type if self.supports_type != "simple" else "pinned" + ) + self.support_definitions[top_right] = ( + self.supports_type if self.supports_type != "simple" else "roller" + ) + + class RoofTruss(Truss): # Additional geometry for this truss type overhang_length: float @@ -192,6 +279,7 @@ class RoofTruss(Truss): roof_pitch: float @property + @abstractmethod def type(self) -> str: return "[Generic] Roof Truss" From c9d1afee7d3019b64dba13aa61947583b53f4a1c Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 18 Nov 2025 14:08:44 +1100 Subject: [PATCH 09/23] Add more roof truss types --- anastruct/truss/truss.py | 597 +++++++++++++++++++++++++++------------ 1 file changed, 413 insertions(+), 184 deletions(-) diff --git a/anastruct/truss/truss.py b/anastruct/truss/truss.py index ac3eeac0..70bfff15 100644 --- a/anastruct/truss/truss.py +++ b/anastruct/truss/truss.py @@ -2,67 +2,20 @@ import numpy as np -from anastruct.truss.truss_class import DEFAULT_TRUSS_SECTION, RoofTruss, Truss +from anastruct.truss.truss_class import ( + DEFAULT_TRUSS_SECTION, + FlatTruss, + RoofTruss, + Truss, +) from anastruct.types import SectionProps, Vertex -class HoweFlatTruss(Truss): - # Data types specific to this truss type - EndType = Literal["flat", "triangle_down", "triangle_up"] - SupportLoc = Literal["bottom_chord", "top_chord", "both"] - - # Additional geometry for this truss type - unit_width: float - end_type: EndType - supports_loc: SupportLoc - - # Addtional configuration - min_end_fraction: float - enforce_even_units: bool - - # Computed properties - n_units: int - end_width: float - +class HoweFlatTruss(FlatTruss): @property def type(self) -> str: return "Howe Flat Truss" - def __init__( - self, - width: float, - height: float, - unit_width: float, - end_type: EndType = "triangle_down", - supports_loc: SupportLoc = "bottom_chord", - min_end_fraction: float = 0.5, - enforce_even_units: bool = True, - top_chord_section: Optional[SectionProps] = None, - bottom_chord_section: Optional[SectionProps] = None, - web_section: Optional[SectionProps] = None, - web_verticals_section: Optional[SectionProps] = None, - ): - - self.unit_width = unit_width - self.end_type = end_type - self.supports_loc = supports_loc - self.min_end_fraction = min_end_fraction - self.enforce_even_units = enforce_even_units - self.n_units = np.floor( - (width - unit_width * 2 * min_end_fraction) / unit_width - ) - if self.enforce_even_units and self.n_units % 2 != 0: - self.n_units -= 1 - self.end_width = (width - self.n_units * unit_width) / 2 - super().__init__( - width, - height, - top_chord_section, - bottom_chord_section, - web_section, - web_verticals_section, - ) - def define_nodes(self) -> None: # Bottom chord nodes if self.end_type != "triangle_up": @@ -144,83 +97,12 @@ def define_connectivity(self) -> None: ): self.web_verticals_node_pairs.append((b, t)) - def define_supports(self) -> None: - bottom_left = 0 - bottom_right = max(self.bottom_chord_node_ids[0]) - top_left = min(self.top_chord_node_ids[0]) - top_right = max(self.top_chord_node_ids[0]) - if self.supports_loc in ["bottom_chord", "both"]: - self.support_definitions[bottom_left] = ( - self.supports_type if self.supports_type != "simple" else "pinned" - ) - self.support_definitions[bottom_right] = ( - self.supports_type if self.supports_type != "simple" else "roller" - ) - if self.supports_loc in ["top_chord", "both"]: - self.support_definitions[top_left] = ( - self.supports_type if self.supports_type != "simple" else "pinned" - ) - self.support_definitions[top_right] = ( - self.supports_type if self.supports_type != "simple" else "roller" - ) - - -class PrattFlatTruss(Truss): - # Data types specific to this truss type - EndType = Literal["flat", "triangle_down", "triangle_up"] - SupportLoc = Literal["bottom_chord", "top_chord", "both"] - - # Additional geometry for this truss type - unit_width: float - end_type: EndType - supports_loc: SupportLoc - - # Addtional configuration - min_end_fraction: float - enforce_even_units: bool - - # Computed properties - n_units: int - end_width: float +class PrattFlatTruss(FlatTruss): @property def type(self) -> str: return "Pratt Flat Truss" - def __init__( - self, - width: float, - height: float, - unit_width: float, - end_type: EndType = "triangle_down", - supports_loc: SupportLoc = "bottom_chord", - min_end_fraction: float = 0.5, - enforce_even_units: bool = True, - top_chord_section: Optional[SectionProps] = None, - bottom_chord_section: Optional[SectionProps] = None, - web_section: Optional[SectionProps] = None, - web_verticals_section: Optional[SectionProps] = None, - ): - self.unit_width = unit_width - self.end_type = end_type - self.supports_loc = supports_loc - self.min_end_fraction = min_end_fraction - self.enforce_even_units = enforce_even_units - self.n_units = np.floor( - (width - unit_width * 2 * min_end_fraction) / unit_width - ) - if self.enforce_even_units and self.n_units % 2 != 0: - self.n_units -= 1 - self.end_width = (width - self.n_units * unit_width) / 2 - super().__init__( - width, - height, - top_chord_section, - bottom_chord_section, - web_section, - web_verticals_section, - ) - def define_nodes(self) -> None: # Bottom chord nodes if self.end_type != "triangle_up": @@ -302,28 +184,8 @@ def define_connectivity(self) -> None: ): self.web_verticals_node_pairs.append((b, t)) - def define_supports(self) -> None: - bottom_left = 0 - bottom_right = max(self.bottom_chord_node_ids[0]) - top_left = min(self.top_chord_node_ids[0]) - top_right = max(self.top_chord_node_ids[0]) - if self.supports_loc in ["bottom_chord", "both"]: - self.support_definitions[bottom_left] = ( - self.supports_type if self.supports_type != "simple" else "pinned" - ) - self.support_definitions[bottom_right] = ( - self.supports_type if self.supports_type != "simple" else "roller" - ) - if self.supports_loc in ["top_chord", "both"]: - self.support_definitions[top_left] = ( - self.supports_type if self.supports_type != "simple" else "pinned" - ) - self.support_definitions[top_right] = ( - self.supports_type if self.supports_type != "simple" else "roller" - ) - -class WarrenFlatTruss(Truss): +class WarrenFlatTruss(FlatTruss): # Data types specific to this truss type EndType = Literal["triangle_down", "triangle_up"] SupportLoc = Literal["bottom_chord", "top_chord", "both"] @@ -353,24 +215,25 @@ def __init__( web_section: Optional[SectionProps] = None, web_verticals_section: Optional[SectionProps] = None, ): - self.unit_width = unit_width - self.end_type = end_type - self.supports_loc = supports_loc # Note that the maths for a Warren truss is simpler than for Howe/Pratt, because there # cannot be any option for non-even number of units, and there are no special cases for # web verticals. - self.n_units = np.floor(width / unit_width) - if self.n_units % 2 != 0: - self.n_units -= 1 - self.end_width = (width - self.n_units * unit_width) / 2 + (unit_width / 2) + min_end_fraction = 0.5 # Not used for Warren truss + enforce_even_units = True # Handled internally for Warren truss super().__init__( width, height, + unit_width, + end_type, + supports_loc, + min_end_fraction, + enforce_even_units, top_chord_section, bottom_chord_section, web_section, web_verticals_section, ) + self.end_width = (width - self.n_units * unit_width) / 2 + (unit_width / 2) def define_nodes(self) -> None: # Bottom chord nodes @@ -433,26 +296,6 @@ def define_connectivity(self) -> None: ): self.web_node_pairs.append((b, t)) - def define_supports(self) -> None: - bottom_left = 0 - bottom_right = max(self.bottom_chord_node_ids[0]) - top_left = min(self.top_chord_node_ids[0]) - top_right = max(self.top_chord_node_ids[0]) - if self.supports_loc in ["bottom_chord", "both"]: - self.support_definitions[bottom_left] = ( - self.supports_type if self.supports_type != "simple" else "pinned" - ) - self.support_definitions[bottom_right] = ( - self.supports_type if self.supports_type != "simple" else "roller" - ) - if self.supports_loc in ["top_chord", "both"]: - self.support_definitions[top_left] = ( - self.supports_type if self.supports_type != "simple" else "pinned" - ) - self.support_definitions[top_right] = ( - self.supports_type if self.supports_type != "simple" else "roller" - ) - class KingPostRoofTruss(RoofTruss): @property @@ -558,13 +401,13 @@ def type(self) -> str: def define_nodes(self) -> None: # Bottom chord nodes self.nodes.append(Vertex(0.0, 0.0)) - self.nodes.append(Vertex(self.width / 3, 0.0)) + self.nodes.append(Vertex(1 * self.width / 3, 0.0)) self.nodes.append(Vertex(2 * self.width / 3, 0.0)) self.nodes.append(Vertex(self.width, 0.0)) # Top chord nodes # self.nodes.append(Vertex(0.0, 0.0)) - self.nodes.append(Vertex(self.width / 4, self.height / 2)) + self.nodes.append(Vertex(1 * self.width / 4, self.height / 2)) self.nodes.append(Vertex(self.width / 2, self.height)) self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) # self.nodes.append(Vertex(self.width, 0.0)) @@ -609,14 +452,14 @@ def type(self) -> str: def define_nodes(self) -> None: # Bottom chord nodes self.nodes.append(Vertex(0.0, 0.0)) - self.nodes.append(Vertex(self.width / 4, 0.0)) + self.nodes.append(Vertex(1 * self.width / 4, 0.0)) self.nodes.append(Vertex(self.width / 2, 0.0)) self.nodes.append(Vertex(3 * self.width / 4, 0.0)) self.nodes.append(Vertex(self.width, 0.0)) # Top chord nodes # self.nodes.append(Vertex(0.0, 0.0)) - self.nodes.append(Vertex(self.width / 4, self.height / 2)) + self.nodes.append(Vertex(1 * self.width / 4, self.height / 2)) self.nodes.append(Vertex(self.width / 2, self.height)) self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) # self.nodes.append(Vertex(self.width, 0.0)) @@ -638,7 +481,7 @@ def define_connectivity(self) -> None: # Bottom chord connectivity self.bottom_chord_node_ids = [[0, 1, 2, 3, 4]] left_v = 0 - right_v = 2 + right_v = 4 # Top chord connectivity (left and right slopes stored separately) self.top_chord_node_ids = [[left_v, 5, 6], [6, 7, right_v]] @@ -664,14 +507,14 @@ def type(self) -> str: def define_nodes(self) -> None: # Bottom chord nodes self.nodes.append(Vertex(0.0, 0.0)) - self.nodes.append(Vertex(self.width / 4, 0.0)) + self.nodes.append(Vertex(1 * self.width / 4, 0.0)) self.nodes.append(Vertex(self.width / 2, 0.0)) self.nodes.append(Vertex(3 * self.width / 4, 0.0)) self.nodes.append(Vertex(self.width, 0.0)) # Top chord nodes # self.nodes.append(Vertex(0.0, 0.0)) - self.nodes.append(Vertex(self.width / 4, self.height / 2)) + self.nodes.append(Vertex(1 * self.width / 4, self.height / 2)) self.nodes.append(Vertex(self.width / 2, self.height)) self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) # self.nodes.append(Vertex(self.width, 0.0)) @@ -693,7 +536,7 @@ def define_connectivity(self) -> None: # Bottom chord connectivity self.bottom_chord_node_ids = [[0, 1, 2, 3, 4]] left_v = 0 - right_v = 2 + right_v = 4 # Top chord connectivity (left and right slopes stored separately) self.top_chord_node_ids = [[left_v, 5, 6], [6, 7, right_v]] @@ -719,13 +562,13 @@ def type(self) -> str: def define_nodes(self) -> None: # Bottom chord nodes self.nodes.append(Vertex(0.0, 0.0)) - self.nodes.append(Vertex(self.width / 3, 0.0)) + self.nodes.append(Vertex(1 * self.width / 3, 0.0)) self.nodes.append(Vertex(2 * self.width / 3, 0.0)) self.nodes.append(Vertex(self.width, 0.0)) # Top chord nodes # self.nodes.append(Vertex(0.0, 0.0)) - self.nodes.append(Vertex(self.width / 6, self.height / 3)) + self.nodes.append(Vertex(1 * self.width / 6, self.height / 3)) self.nodes.append(Vertex(2 * self.width / 6, 2 * self.height / 3)) self.nodes.append(Vertex(self.width / 2, self.height)) self.nodes.append(Vertex(4 * self.width / 6, 2 * self.height / 3)) @@ -768,6 +611,392 @@ def define_connectivity(self) -> None: self.web_verticals_node_pairs.append((2, 7)) +class ModifiedQueenPostRoofTruss(RoofTruss): + @property + def type(self) -> str: + return "Modified Queen Post Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(3 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 6, self.height / 3)) + self.nodes.append(Vertex(2 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(4 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(5 * self.width / 6, self.height / 3)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [[0, 1, 2, 3, 4]] + left_v = 0 + right_v = 4 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [[left_v, 5, 6, 7], [7, 8, 9, right_v]] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 10) # left overhang + self.top_chord_node_ids[1].append(11) # 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((3, 9)) + + # Web verticals connectivity + self.web_verticals_node_pairs.append((2, 7)) # center vertical + + +class DoubleFinkRoofTruss(RoofTruss): + @property + def type(self) -> str: + return "Double Fink Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 5, 0.0)) + self.nodes.append(Vertex(2 * self.width / 5, 0.0)) + self.nodes.append(Vertex(3 * self.width / 5, 0.0)) + self.nodes.append(Vertex(4 * self.width / 5, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 6, self.height / 3)) + self.nodes.append(Vertex(2 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(4 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(5 * self.width / 6, self.height / 3)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + 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 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [[left_v, 6, 7, 8], [8, 9, 10, right_v]] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 11) # left overhang + self.top_chord_node_ids[1].append(12) # 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)) + + +class DoubleHoweRoofTruss(RoofTruss): + @property + def type(self) -> str: + return "Double Howe Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 6, 0.0)) + self.nodes.append(Vertex(2 * self.width / 6, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(4 * self.width / 6, 0.0)) + self.nodes.append(Vertex(5 * self.width / 6, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 6, self.height / 3)) + self.nodes.append(Vertex(2 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(4 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(5 * self.width / 6, self.height / 3)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + 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 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [[left_v, 7, 8, 9], [9, 10, 11, right_v]] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 12) # left overhang + self.top_chord_node_ids[1].append(13) # 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, 11)) + + # 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((5, 11)) + + +class ModifiedFanRoofTruss(RoofTruss): + @property + def type(self) -> str: + return "Modified Fan Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(3 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 8, 1 * self.height / 4)) + self.nodes.append(Vertex(2 * self.width / 8, 2 * self.height / 4)) + self.nodes.append(Vertex(3 * self.width / 8, 3 * self.height / 4)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(5 * self.width / 8, 3 * self.height / 4)) + self.nodes.append(Vertex(6 * self.width / 8, 2 * self.height / 4)) + self.nodes.append(Vertex(7 * self.width / 8, 1 * self.height / 4)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [[0, 1, 2, 3, 4]] + left_v = 0 + right_v = 4 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [[left_v, 5, 6, 7, 8], [8, 9, 10, 11, right_v]] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 12) # left overhang + self.top_chord_node_ids[1].append(13) # 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)) + + # 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)) + + +class AtticRoofTruss(RoofTruss): + # Additional properties for this truss type + attic_width: float + attic_height: float + + # Computed properties for this truss type + wall_x: float + wall_y: float + ceiling_y: float + ceiling_x: float + wall_ceiling_intersect: bool = False + + @property + def type(self) -> str: + return "Attic Roof Truss" + + def __init__( + self, + width: float, + roof_pitch_deg: float, + attic_width: float, + attic_height: Optional[float] = None, + overhang_length: float = 0.0, + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + ): + super().__init__( + width=width, + roof_pitch_deg=roof_pitch_deg, + overhang_length=overhang_length, + top_chord_section=top_chord_section, + bottom_chord_section=bottom_chord_section, + web_section=web_section, + web_verticals_section=web_verticals_section, + ) + self.attic_width = attic_width + self.wall_x = self.width / 2 - self.attic_width / 2 + self.wall_y = self.wall_x * np.tan(self.roof_pitch) + if attic_height is None: + self.attic_height = self.wall_y + else: + self.attic_height = attic_height + self.ceiling_y = self.attic_height + self.ceiling_x = self.width / 2 - (self.height - self.ceiling_y) / np.tan( + self.roof_pitch + ) + if self.ceiling_y == self.wall_y: + self.wall_ceiling_intersect = True + if self.ceiling_y < self.wall_y or self.ceiling_x < self.wall_y: + raise ValueError( + "Attic height may not be less than the attic wall height. Please increase your attic width and/or attic height." + ) + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.wall_x, 0.0)) + self.nodes.append(Vertex(self.width - self.wall_x, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.wall_x / 2, self.wall_y / 2)) + self.nodes.append(Vertex(self.wall_x, self.wall_y)) + if not self.wall_ceiling_intersect: + self.nodes.append(Vertex(self.ceiling_x, self.ceiling_y)) + self.nodes.append(Vertex(self.width / 2, self.height)) + if not self.wall_ceiling_intersect: + self.nodes.append(Vertex(self.width - self.ceiling_x, self.ceiling_y)) + self.nodes.append(Vertex(self.width - self.wall_x, self.wall_y)) + self.nodes.append(Vertex(self.width - self.wall_x / 2, self.wall_y / 2)) + self.nodes.append( + Vertex(self.width / 2, self.ceiling_y) + ) # special node in the middle of the ceiling beam + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [[0, 1, 2, 3]] + left_v = 0 + right_v = 3 + + if self.wall_ceiling_intersect: + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [ + [left_v, 4, 5, 6], + [6, 7, 8, right_v], + [5, 9, 7], # attic ceiling + ] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 10) # left overhang + self.top_chord_node_ids[1].append(11) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 4)) + self.web_node_pairs.append( + (9, 6) + ) # special case: this is actually the center vertical post + self.web_node_pairs.append((2, 8)) + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 5)) + self.web_verticals_node_pairs.append((2, 7)) + + else: + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = [ + [left_v, 4, 5, 6, 7], + [7, 8, 9, 10, right_v], + [6, 11, 8], # attic ceiling + ] + if self.overhang_length > 0: + self.top_chord_node_ids[0].insert(0, 12) # left overhang + self.top_chord_node_ids[1].append(13) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 4)) + self.web_node_pairs.append( + (11, 7) + ) # special case: this is actually the center vertical post + self.web_node_pairs.append((2, 10)) + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 5)) + self.web_verticals_node_pairs.append((2, 9)) + + __all__ = [ "HoweFlatTruss", "PrattFlatTruss", From e27004ae6d1eed32e5760944d95c22fa2a603413 Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 18 Nov 2025 14:10:03 +1100 Subject: [PATCH 10/23] Fill __all__ list --- anastruct/truss/truss.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/anastruct/truss/truss.py b/anastruct/truss/truss.py index 70bfff15..e3d627c4 100644 --- a/anastruct/truss/truss.py +++ b/anastruct/truss/truss.py @@ -1007,4 +1007,9 @@ def define_connectivity(self) -> None: "HoweRoofTruss", "PrattRoofTruss", "FanRoofTruss", + "ModifiedQueenPostRoofTruss", + "DoubleFinkRoofTruss", + "DoubleHoweRoofTruss", + "ModifiedFanRoofTruss", + "AtticRoofTruss", ] From 2400d333de0ae6c7107347d1ae96d6ecfaeae91a Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 18 Nov 2025 14:10:46 +1100 Subject: [PATCH 11/23] Remove disused imports --- anastruct/truss/truss.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/anastruct/truss/truss.py b/anastruct/truss/truss.py index e3d627c4..7840bc34 100644 --- a/anastruct/truss/truss.py +++ b/anastruct/truss/truss.py @@ -2,12 +2,7 @@ import numpy as np -from anastruct.truss.truss_class import ( - DEFAULT_TRUSS_SECTION, - FlatTruss, - RoofTruss, - Truss, -) +from anastruct.truss.truss_class import FlatTruss, RoofTruss from anastruct.types import SectionProps, Vertex From c592ef6d8577e9e09536b871c6c9bb9d65a40133 Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:30:06 +1100 Subject: [PATCH 12/23] Rename truss -> preprocess --- anastruct/__init__.py | 2 +- anastruct/preprocess/__init__.py | 0 anastruct/{truss => preprocess}/truss.py | 2 +- anastruct/{truss => preprocess}/truss_class.py | 0 4 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 anastruct/preprocess/__init__.py rename anastruct/{truss => preprocess}/truss.py (99%) rename anastruct/{truss => preprocess}/truss_class.py (100%) diff --git a/anastruct/__init__.py b/anastruct/__init__.py index 6b59dbca..028b3d95 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.truss import truss +from anastruct.preprocess import truss from anastruct.vertex import Vertex diff --git a/anastruct/preprocess/__init__.py b/anastruct/preprocess/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/anastruct/truss/truss.py b/anastruct/preprocess/truss.py similarity index 99% rename from anastruct/truss/truss.py rename to anastruct/preprocess/truss.py index 7840bc34..b4ae4c6d 100644 --- a/anastruct/truss/truss.py +++ b/anastruct/preprocess/truss.py @@ -2,7 +2,7 @@ import numpy as np -from anastruct.truss.truss_class import FlatTruss, RoofTruss +from anastruct.preprocess.truss_class import FlatTruss, RoofTruss from anastruct.types import SectionProps, Vertex diff --git a/anastruct/truss/truss_class.py b/anastruct/preprocess/truss_class.py similarity index 100% rename from anastruct/truss/truss_class.py rename to anastruct/preprocess/truss_class.py From 09e3003fc384c1bedb632081131a2171e7ba4345 Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Fri, 21 Nov 2025 20:34:44 +1100 Subject: [PATCH 13/23] Add .coverage to .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 48c5fe40..4aa83a68 100644 --- a/.gitignore +++ b/.gitignore @@ -89,4 +89,5 @@ MANIFEST build/ .ipynb_checkpoints/ -dev/ \ No newline at end of file +dev/ +.coverage \ No newline at end of file From faeea84a8ff52a8acb018df1c0c89542f4f6967d Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:50:46 +1100 Subject: [PATCH 14/23] Store element IDs too --- anastruct/preprocess/truss_class.py | 48 +++++++++++++++++++---------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/anastruct/preprocess/truss_class.py b/anastruct/preprocess/truss_class.py index f8b24bf3..063fcfb0 100644 --- a/anastruct/preprocess/truss_class.py +++ b/anastruct/preprocess/truss_class.py @@ -40,6 +40,12 @@ class Truss(ABC): top_chord_length: float = 0.0 bottom_chord_length: float = 0.0 + # Defined by main class + top_chord_element_ids: list[list[int]] = [] + bottom_chord_element_ids: list[list[int]] = [] + web_element_ids: list[int] = [] + web_verticals_element_ids: list[int] = [] + # System system: SystemElements @@ -100,41 +106,49 @@ def add_segment_elements( node_pairs: Iterable[tuple[int, int]], section: SectionProps, continuous: bool, - ) -> None: + ) -> list[int]: + element_ids = [] for i, j in node_pairs: - self.system.add_element( - location=(self.nodes[i], self.nodes[j]), - EA=section["EA"], - EI=section["EI"], - g=section["g"], - spring=None if continuous else {1: 0.0, 2: 0.0}, + element_ids.append( + self.system.add_element( + location=(self.nodes[i], self.nodes[j]), + EA=section["EA"], + EI=section["EI"], + g=section["g"], + spring=None if continuous else {1: 0.0, 2: 0.0}, + ) ) + return element_ids # Bottom chord elements for segment_node_ids in self.bottom_chord_node_ids: - add_segment_elements( - node_pairs=zip(segment_node_ids[:-1], segment_node_ids[1:]), - section=self.bottom_chord_section, - continuous=self.bottom_chord_continuous, + self.bottom_chord_element_ids.append( + add_segment_elements( + node_pairs=zip(segment_node_ids[:-1], segment_node_ids[1:]), + section=self.bottom_chord_section, + continuous=self.bottom_chord_continuous, + ) ) # Top chord elements for segment_node_ids in self.top_chord_node_ids: - add_segment_elements( - node_pairs=zip(segment_node_ids[:-1], segment_node_ids[1:]), - section=self.top_chord_section, - continuous=self.top_chord_continous, + self.top_chord_element_ids.append( + add_segment_elements( + node_pairs=zip(segment_node_ids[:-1], segment_node_ids[1:]), + section=self.top_chord_section, + continuous=self.top_chord_continous, + ) ) # Web diagonal elements - add_segment_elements( + self.web_element_ids = add_segment_elements( node_pairs=self.web_node_pairs, section=self.web_section, continuous=False, ) # Web vertical elements - add_segment_elements( + self.web_verticals_element_ids = add_segment_elements( node_pairs=self.web_verticals_node_pairs, section=self.web_verticals_section, continuous=False, From 3e2b3d368a8684bdb85bef3273019af78ac52988 Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:19:28 +1100 Subject: [PATCH 15/23] Switch to dicts, add quick q_loads to class --- anastruct/preprocess/truss_class.py | 125 ++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 33 deletions(-) diff --git a/anastruct/preprocess/truss_class.py b/anastruct/preprocess/truss_class.py index 063fcfb0..457af212 100644 --- a/anastruct/preprocess/truss_class.py +++ b/anastruct/preprocess/truss_class.py @@ -32,8 +32,8 @@ class Truss(ABC): # Defined by subclass nodes: list[Vertex] = [] - top_chord_node_ids: list[list[int]] = [] - bottom_chord_node_ids: list[list[int]] = [] + top_chord_node_ids: Union[list[int], dict[str, list[int]]] = [] + bottom_chord_node_ids: Union[list[int], dict[str, list[int]]] = [] web_node_pairs: list[tuple[int, int]] = [] web_verticals_node_pairs: list[tuple[int, int]] = [] support_definitions: dict[int, Literal["fixed", "pinned", "roller"]] = {} @@ -41,8 +41,8 @@ class Truss(ABC): bottom_chord_length: float = 0.0 # Defined by main class - top_chord_element_ids: list[list[int]] = [] - bottom_chord_element_ids: list[list[int]] = [] + top_chord_element_ids: Union[list[int], dict[str, list[int]]] = [] + bottom_chord_element_ids: Union[list[int], dict[str, list[int]]] = [] web_element_ids: list[int] = [] web_verticals_element_ids: list[int] = [] @@ -57,7 +57,7 @@ def __init__( bottom_chord_section: Optional[SectionProps] = None, web_section: Optional[SectionProps] = None, web_verticals_section: Optional[SectionProps] = None, - top_chord_continous: bool = True, + top_chord_continuous: bool = True, bottom_chord_continuous: bool = True, supports_type: Literal["simple", "pinned", "fixed"] = "simple", ): @@ -67,7 +67,7 @@ def __init__( self.bottom_chord_section = bottom_chord_section or DEFAULT_TRUSS_SECTION self.web_section = web_section or DEFAULT_TRUSS_SECTION self.web_verticals_section = web_verticals_section or self.web_section - self.top_chord_continous = top_chord_continous + self.top_chord_continuous = top_chord_continuous self.bottom_chord_continuous = bottom_chord_continuous self.supports_type = supports_type @@ -121,23 +121,39 @@ def add_segment_elements( return element_ids # Bottom chord elements - for segment_node_ids in self.bottom_chord_node_ids: - self.bottom_chord_element_ids.append( - add_segment_elements( + if isinstance(self.bottom_chord_node_ids, dict): + self.bottom_chord_element_ids = {} + for key, segment_node_ids in self.bottom_chord_node_ids.items(): + self.bottom_chord_element_ids[key] = add_segment_elements( node_pairs=zip(segment_node_ids[:-1], segment_node_ids[1:]), section=self.bottom_chord_section, continuous=self.bottom_chord_continuous, ) + else: + self.bottom_chord_element_ids = add_segment_elements( + node_pairs=zip( + self.bottom_chord_node_ids[:-1], self.bottom_chord_node_ids[1:] + ), + section=self.bottom_chord_section, + continuous=self.bottom_chord_continuous, ) # Top chord elements - for segment_node_ids in self.top_chord_node_ids: - self.top_chord_element_ids.append( - add_segment_elements( + if isinstance(self.top_chord_node_ids, dict): + self.top_chord_element_ids = {} + for key, segment_node_ids in self.top_chord_node_ids.items(): + self.top_chord_element_ids[key] = add_segment_elements( node_pairs=zip(segment_node_ids[:-1], segment_node_ids[1:]), section=self.top_chord_section, - continuous=self.top_chord_continous, + continuous=self.top_chord_continuous, ) + else: + self.top_chord_element_ids = add_segment_elements( + node_pairs=zip( + self.top_chord_node_ids[:-1], self.top_chord_node_ids[1:] + ), + section=self.top_chord_section, + continuous=self.top_chord_continuous, ) # Web diagonal elements @@ -163,35 +179,70 @@ def add_supports(self) -> None: elif support_type == "roller": self.system.add_support_roll(node_id=node_id) - def apply_q_loads_to_top_chord( + def get_element_ids_of_chord( + self, chord: Literal["top", "bottom"], chord_segment: Optional[str] = None + ) -> list[int]: + if chord == "top": + if isinstance(self.top_chord_element_ids, dict): + if chord_segment is None: + all_ids = [] + for ids in self.top_chord_element_ids.values(): + all_ids.extend(ids) + return all_ids + return self.top_chord_element_ids[chord_segment] + return self.top_chord_element_ids + + if chord == "bottom": + if isinstance(self.bottom_chord_element_ids, dict): + if chord_segment is None: + all_ids = [] + for ids in self.bottom_chord_element_ids.values(): + all_ids.extend(ids) + return all_ids + return self.bottom_chord_element_ids[chord_segment] + return self.bottom_chord_element_ids + + raise ValueError("chord must be either 'top' or 'bottom'.") + + def apply_q_load_to_top_chord( 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, + chord_segment: Optional[str] = None, ) -> None: - pass + element_ids = self.get_element_ids_of_chord( + chord="top", chord_segment=chord_segment + ) + 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_q_loads_to_bottom_chord( + def apply_q_load_to_bottom_chord( self, - x_start: float, - x_end: float, 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, + chord_segment: Optional[str] = None, ) -> None: - pass - - def apply_point_load_to_top_chord( - self, x_loc: float, Fx: float = 0.0, Fy: float = 0.0, rotation: float = 0.0 - ) -> None: - pass - - def apply_point_load_to_bottom_chord( - self, x_loc: float, Fx: float = 0.0, Fy: float = 0.0, rotation: float = 0.0 - ) -> None: - pass + element_ids = self.get_element_ids_of_chord( + chord="bottom", chord_segment=chord_segment + ) + 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 show_structure(self) -> None: self.system.show_structure() @@ -264,10 +315,14 @@ def define_connectivity(self) -> None: pass def define_supports(self) -> None: + # This default implementation assumes that top and bottom chords are one single segment / list, and + # that supports are at the ends of the truss. + assert isinstance(self.bottom_chord_node_ids, list) + assert isinstance(self.top_chord_node_ids, list) bottom_left = 0 - bottom_right = max(self.bottom_chord_node_ids[0]) - top_left = min(self.top_chord_node_ids[0]) - top_right = max(self.top_chord_node_ids[0]) + bottom_right = max(self.bottom_chord_node_ids) + top_left = min(self.top_chord_node_ids) + top_right = max(self.top_chord_node_ids) if self.supports_loc in ["bottom_chord", "both"]: self.support_definitions[bottom_left] = ( self.supports_type if self.supports_type != "simple" else "pinned" @@ -329,8 +384,12 @@ def define_connectivity(self) -> None: pass def define_supports(self) -> None: + # This default implementation assumes that bottom chords are one single segment / list, and + # that supports are at the ends of the truss. + assert isinstance(self.bottom_chord_node_ids, list) + bottom_left = 0 - bottom_right = max(self.bottom_chord_node_ids[0]) + bottom_right = max(self.bottom_chord_node_ids) self.support_definitions[bottom_left] = ( self.supports_type if self.supports_type != "simple" else "pinned" ) From 4dae8234127a78a6f7d8c520e4806acb3b2a2c41 Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:24:45 +1100 Subject: [PATCH 16/23] Update individual trusses per data type changes --- anastruct/preprocess/truss.py | 181 ++++++++++++++++++---------------- 1 file changed, 98 insertions(+), 83 deletions(-) diff --git a/anastruct/preprocess/truss.py b/anastruct/preprocess/truss.py index b4ae4c6d..49a73baf 100644 --- a/anastruct/preprocess/truss.py +++ b/anastruct/preprocess/truss.py @@ -39,12 +39,12 @@ 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(0, n_bottom_nodes)) # Top chord connectivity - self.top_chord_node_ids = [ - list(range(n_bottom_nodes, n_bottom_nodes + n_top_nodes)) - ] + self.top_chord_node_ids = list( + range(n_bottom_nodes, n_bottom_nodes + n_top_nodes) + ) # Web diagonals connectivity start_bot = 0 @@ -65,13 +65,13 @@ def define_connectivity(self) -> None: mid_bot = len(self.bottom_chord_node_ids) // 2 mid_top = len(self.top_chord_node_ids) // 2 for b, t in zip( - self.bottom_chord_node_ids[0][start_bot : mid_bot + 1], - self.top_chord_node_ids[0][start_top : mid_top + 1], + self.bottom_chord_node_ids[start_bot : mid_bot + 1], + self.top_chord_node_ids[start_top : mid_top + 1], ): self.web_node_pairs.append((b, t)) for b, t in zip( - self.bottom_chord_node_ids[0][end_bot : mid_bot - 1 : -1], - self.top_chord_node_ids[0][end_top : mid_top - 1 : -1], + self.bottom_chord_node_ids[end_bot : mid_bot - 1 : -1], + self.top_chord_node_ids[end_top : mid_top - 1 : -1], ): self.web_node_pairs.append((b, t)) @@ -87,8 +87,8 @@ def define_connectivity(self) -> None: start_bot = 1 end_bot = -1 for b, t in zip( - self.bottom_chord_node_ids[0][start_bot:end_bot], - self.top_chord_node_ids[0][start_top:end_top], + self.bottom_chord_node_ids[start_bot:end_bot], + self.top_chord_node_ids[start_top:end_top], ): self.web_verticals_node_pairs.append((b, t)) @@ -126,12 +126,12 @@ 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(0, n_bottom_nodes)) # Top chord connectivity - self.top_chord_node_ids = [ - list(range(n_bottom_nodes, n_bottom_nodes + n_top_nodes)) - ] + self.top_chord_node_ids = list( + range(n_bottom_nodes, n_bottom_nodes + n_top_nodes) + ) # Web diagonals connectivity start_bot = 0 @@ -152,13 +152,13 @@ def define_connectivity(self) -> None: mid_bot = len(self.bottom_chord_node_ids) // 2 mid_top = len(self.top_chord_node_ids) // 2 for b, t in zip( - self.bottom_chord_node_ids[0][start_bot : mid_bot + 1], - self.top_chord_node_ids[0][start_top : mid_top + 1], + self.bottom_chord_node_ids[start_bot : mid_bot + 1], + self.top_chord_node_ids[start_top : mid_top + 1], ): self.web_node_pairs.append((b, t)) for b, t in zip( - self.bottom_chord_node_ids[0][end_bot : mid_bot - 1 : -1], - self.top_chord_node_ids[0][end_top : mid_top - 1 : -1], + self.bottom_chord_node_ids[end_bot : mid_bot - 1 : -1], + self.top_chord_node_ids[end_top : mid_top - 1 : -1], ): self.web_node_pairs.append((b, t)) @@ -174,8 +174,8 @@ def define_connectivity(self) -> None: start_bot = 1 end_bot = -1 for b, t in zip( - self.bottom_chord_node_ids[0][start_bot:end_bot], - self.top_chord_node_ids[0][start_top:end_top], + self.bottom_chord_node_ids[start_bot:end_bot], + self.top_chord_node_ids[start_top:end_top], ): self.web_verticals_node_pairs.append((b, t)) @@ -268,26 +268,26 @@ def define_connectivity(self) -> None: n_top_nodes = int(self.n_units) + (1 if self.end_type == "triangle_up" else 0) # Bottom chord connectivity - self.bottom_chord_node_ids = [list(range(0, n_bottom_nodes))] + self.bottom_chord_node_ids = list(range(0, n_bottom_nodes)) # Top chord connectivity - self.top_chord_node_ids = [ - list(range(n_bottom_nodes, n_bottom_nodes + n_top_nodes)) - ] + self.top_chord_node_ids = list( + range(n_bottom_nodes, n_bottom_nodes + n_top_nodes) + ) # Web diagonals connectivity # sloping up from bottom left to top right top_start = 0 if self.end_type == "triangle_down" else 1 for b, t in zip( - self.bottom_chord_node_ids[0], - self.top_chord_node_ids[0][top_start:], + self.bottom_chord_node_ids, + self.top_chord_node_ids[top_start:], ): self.web_node_pairs.append((b, t)) # sloping down from top left to bottom right bot_start = 0 if self.end_type == "triangle_up" else 1 for b, t in zip( - self.top_chord_node_ids[0], - self.bottom_chord_node_ids[0][bot_start:], + self.top_chord_node_ids, + self.bottom_chord_node_ids[bot_start:], ): self.web_node_pairs.append((b, t)) @@ -323,15 +323,15 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [[0, 1, 2]] + self.bottom_chord_node_ids = [0, 1, 2] left_v = 0 right_v = 2 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [[left_v, 3], [3, right_v]] + self.top_chord_node_ids = {"left": [left_v, 3], "right": [3, right_v]} if self.overhang_length > 0: - self.top_chord_node_ids[0].insert(0, 4) # left overhang - self.top_chord_node_ids[1].append(5) # right overhang + self.top_chord_node_ids["left"].insert(0, 4) # left overhang + self.top_chord_node_ids["right"].append(5) # right overhang # Web verticals connectivity self.web_verticals_node_pairs.append((1, 3)) # center vertical @@ -370,15 +370,15 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [[0, 1, 2]] + self.bottom_chord_node_ids = [0, 1, 2] left_v = 0 right_v = 2 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [[left_v, 3, 4], [4, 5, right_v]] + self.top_chord_node_ids = {"left": [left_v, 3, 4], "right": [4, 5, right_v]} if self.overhang_length > 0: - self.top_chord_node_ids[0].insert(0, 6) # left overhang - self.top_chord_node_ids[1].append(7) # right overhang + self.top_chord_node_ids["left"].insert(0, 6) # left overhang + self.top_chord_node_ids["right"].append(7) # right overhang # Web diagonals connectivity self.web_node_pairs.append((1, 3)) # left diagonal @@ -422,15 +422,15 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [[0, 1, 2, 3]] + self.bottom_chord_node_ids = [0, 1, 2, 3] left_v = 0 right_v = 3 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [[left_v, 4, 5], [5, 6, 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[0].insert(0, 7) # left overhang - self.top_chord_node_ids[1].append(8) # 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, 4)) @@ -474,15 +474,15 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [[0, 1, 2, 3, 4]] + self.bottom_chord_node_ids = [0, 1, 2, 3, 4] left_v = 0 right_v = 4 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [[left_v, 5, 6], [6, 7, 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[0].insert(0, 8) # left overhang - self.top_chord_node_ids[1].append(9) # 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((2, 5)) # left diagonal @@ -529,15 +529,15 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [[0, 1, 2, 3, 4]] + self.bottom_chord_node_ids = [0, 1, 2, 3, 4] left_v = 0 right_v = 4 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [[left_v, 5, 6], [6, 7, 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[0].insert(0, 8) # left overhang - self.top_chord_node_ids[1].append(9) # 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, 6)) # left diagonal @@ -585,15 +585,18 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [[0, 1, 2, 3]] + self.bottom_chord_node_ids = [0, 1, 2, 3] left_v = 0 right_v = 3 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [[left_v, 4, 5, 6], [6, 7, 8, right_v]] + self.top_chord_node_ids = { + "left": [left_v, 4, 5, 6], + "right": [6, 7, 8, right_v], + } if self.overhang_length > 0: - self.top_chord_node_ids[0].insert(0, 9) # left overhang - self.top_chord_node_ids[1].append(10) # 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, 4)) @@ -643,15 +646,18 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [[0, 1, 2, 3, 4]] + self.bottom_chord_node_ids = [0, 1, 2, 3, 4] left_v = 0 right_v = 4 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [[left_v, 5, 6, 7], [7, 8, 9, right_v]] + self.top_chord_node_ids = { + "left": [left_v, 5, 6, 7], + "right": [7, 8, 9, right_v], + } if self.overhang_length > 0: - self.top_chord_node_ids[0].insert(0, 10) # left overhang - self.top_chord_node_ids[1].append(11) # 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, 5)) @@ -703,15 +709,18 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [[0, 1, 2, 3, 4, 5]] + self.bottom_chord_node_ids = [0, 1, 2, 3, 4, 5] left_v = 0 right_v = 5 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [[left_v, 6, 7, 8], [8, 9, 10, right_v]] + self.top_chord_node_ids = { + "left": [left_v, 6, 7, 8], + "right": [8, 9, 10, right_v], + } if self.overhang_length > 0: - self.top_chord_node_ids[0].insert(0, 11) # left overhang - self.top_chord_node_ids[1].append(12) # 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, 6)) @@ -763,15 +772,18 @@ 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]] + self.bottom_chord_node_ids = [0, 1, 2, 3, 4, 5, 6] left_v = 0 right_v = 6 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [[left_v, 7, 8, 9], [9, 10, 11, right_v]] + self.top_chord_node_ids = { + "left": [left_v, 7, 8, 9], + "right": [9, 10, 11, right_v], + } if self.overhang_length > 0: - self.top_chord_node_ids[0].insert(0, 12) # left overhang - self.top_chord_node_ids[1].append(13) # 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((2, 7)) @@ -826,15 +838,18 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [[0, 1, 2, 3, 4]] + self.bottom_chord_node_ids = [0, 1, 2, 3, 4] left_v = 0 right_v = 4 # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [[left_v, 5, 6, 7, 8], [8, 9, 10, 11, right_v]] + self.top_chord_node_ids = { + "left": [left_v, 5, 6, 7, 8], + "right": [8, 9, 10, 11, right_v], + } if self.overhang_length > 0: - self.top_chord_node_ids[0].insert(0, 12) # left overhang - self.top_chord_node_ids[1].append(13) # 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, 5)) @@ -943,20 +958,20 @@ def define_nodes(self) -> None: def define_connectivity(self) -> None: # Bottom chord connectivity - self.bottom_chord_node_ids = [[0, 1, 2, 3]] + self.bottom_chord_node_ids = [0, 1, 2, 3] left_v = 0 right_v = 3 if self.wall_ceiling_intersect: # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [ - [left_v, 4, 5, 6], - [6, 7, 8, right_v], - [5, 9, 7], # attic ceiling - ] + self.top_chord_node_ids = { + "left": [left_v, 4, 5, 6], + "right": [6, 7, 8, right_v], + "ceiling": [5, 9, 7], # attic ceiling + } if self.overhang_length > 0: - self.top_chord_node_ids[0].insert(0, 10) # left overhang - self.top_chord_node_ids[1].append(11) # 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)) @@ -971,14 +986,14 @@ def define_connectivity(self) -> None: else: # Top chord connectivity (left and right slopes stored separately) - self.top_chord_node_ids = [ - [left_v, 4, 5, 6, 7], - [7, 8, 9, 10, right_v], - [6, 11, 8], # attic ceiling - ] + 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 + } if self.overhang_length > 0: - self.top_chord_node_ids[0].insert(0, 12) # left overhang - self.top_chord_node_ids[1].append(13) # 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, 4)) From 89083892689de15e5fef34ab1219d8d3e2fb86ee Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:08:30 +1100 Subject: [PATCH 17/23] Add docstrings everywhere, fix a couple minor bugs / typos --- anastruct/preprocess/truss.py | 223 ++++++++++++++++-- anastruct/preprocess/truss_class.py | 341 ++++++++++++++++++++++++---- 2 files changed, 494 insertions(+), 70 deletions(-) diff --git a/anastruct/preprocess/truss.py b/anastruct/preprocess/truss.py index 49a73baf..100e45ed 100644 --- a/anastruct/preprocess/truss.py +++ b/anastruct/preprocess/truss.py @@ -7,6 +7,13 @@ class HoweFlatTruss(FlatTruss): + """Howe flat truss with vertical web members and diagonal members in compression. + + The Howe truss features vertical web members and diagonal members sloping toward + the center. Under gravity loads, diagonals are typically in compression and + verticals in tension, making it efficient for steel trusses. + """ + @property def type(self) -> str: return "Howe Flat Truss" @@ -94,6 +101,13 @@ def define_connectivity(self) -> None: class PrattFlatTruss(FlatTruss): + """Pratt flat truss with vertical web members and diagonal members in tension. + + The Pratt truss features vertical web members and diagonal members sloping away + from the center. Under gravity loads, diagonals are typically in tension and + verticals in compression, making it efficient for a wide range of applications. + """ + @property def type(self) -> str: return "Pratt Flat Truss" @@ -181,6 +195,16 @@ def define_connectivity(self) -> None: class WarrenFlatTruss(FlatTruss): + """Warren flat truss with diagonal-only web members forming a zigzag pattern. + + The Warren truss has no vertical web members (except optionally at midspan). + Diagonal members alternate direction, creating a series of equilateral or + isosceles triangles. This configuration is simple and efficient. + + Note: Warren trusses don't support the "flat" end_type - only "triangle_down" + or "triangle_up". + """ + # Data types specific to this truss type EndType = Literal["triangle_down", "triangle_up"] SupportLoc = Literal["bottom_chord", "top_chord", "both"] @@ -293,6 +317,12 @@ def define_connectivity(self) -> None: class KingPostRoofTruss(RoofTruss): + """King Post roof truss - simplest pitched roof truss with single center vertical. + + Features a single vertical member (king post) at the center supporting the peak. + Suitable for short spans (up to ~8m). No diagonal web members. + """ + @property def type(self) -> str: return "King Post Roof Truss" @@ -338,22 +368,29 @@ def define_connectivity(self) -> None: class QueenPostRoofTruss(RoofTruss): + """Queen Post roof truss with two vertical members and diagonal bracing. + + Features two vertical members (queen posts) at quarter points with diagonal + members from center to quarter points. Suitable for medium spans (8-15m). + More efficient than King Post for longer spans. + """ + @property def type(self) -> str: return "Queen Post Roof Truss" def define_nodes(self) -> None: - # Bottom chord nodes + # Bottom chord nodes: [0=left, 1=center, 2=right] self.nodes.append(Vertex(0.0, 0.0)) self.nodes.append(Vertex(self.width / 2, 0.0)) self.nodes.append(Vertex(self.width, 0.0)) - # Top chord nodes - # self.nodes.append(Vertex(0.0, 0.0)) + # Top chord nodes: [3=left quarter, 4=peak, 5=right quarter] self.nodes.append(Vertex(self.width / 4, self.height / 2)) self.nodes.append(Vertex(self.width / 2, self.height)) self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) - # self.nodes.append(Vertex(self.width, 0.0)) + + # Optional overhang nodes if self.overhang_length > 0: self.nodes.append( Vertex( @@ -381,14 +418,21 @@ def define_connectivity(self) -> None: self.top_chord_node_ids["right"].append(7) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((1, 3)) # left diagonal - self.web_node_pairs.append((1, 5)) # right diagonal + self.web_node_pairs.append((1, 3)) # left diagonal from center bottom to left quarter top + self.web_node_pairs.append((1, 5)) # right diagonal from center bottom to right quarter top - # Web verticals connectivity - self.web_verticals_node_pairs.append((1, 3)) # center vertical + # Web verticals connectivity - Fixed: should connect to peak (node 4), not node 3 + self.web_verticals_node_pairs.append((1, 4)) # center vertical from center bottom to peak class FinkRoofTruss(RoofTruss): + """Fink roof truss with W-shaped web configuration. + + Features diagonal members forming a W pattern between peak and supports. + Efficient for medium to long spans (10-20m). The symmetrical W pattern + distributes loads effectively with minimal material usage. + """ + @property def type(self) -> str: return "Fink Roof Truss" @@ -440,6 +484,13 @@ def define_connectivity(self) -> None: class HoweRoofTruss(RoofTruss): + """Howe roof truss with vertical posts and diagonal compression members. + + Features vertical posts with diagonals sloping toward the peak. Under gravity + loads, diagonals are in compression and verticals in tension. Suitable for + medium to long spans with good load distribution. + """ + @property def type(self) -> str: return "Howe Roof Truss" @@ -495,6 +546,13 @@ def define_connectivity(self) -> None: class PrattRoofTruss(RoofTruss): + """Pratt roof truss with vertical posts and diagonal tension members. + + Features vertical posts with diagonals sloping away from the peak. Under gravity + loads, diagonals are in tension and verticals in compression. Widely used for + its efficiency and simple construction. + """ + @property def type(self) -> str: return "Pratt Roof Truss" @@ -550,6 +608,13 @@ def define_connectivity(self) -> None: class FanRoofTruss(RoofTruss): + """Fan roof truss with radiating diagonal members forming a fan pattern. + + Features diagonal members radiating from lower chord panel points up to the + top chord, creating a fan-like appearance. Provides excellent load distribution + for longer spans (15-25m). + """ + @property def type(self) -> str: return "Fan Roof Truss" @@ -610,6 +675,13 @@ def define_connectivity(self) -> None: class ModifiedQueenPostRoofTruss(RoofTruss): + """Modified Queen Post roof truss with enhanced web configuration. + + An enhanced version of the Queen Post truss with additional web members + for better load distribution and reduced member forces. Suitable for + medium to long spans (12-20m). + """ + @property def type(self) -> str: return "Modified Queen Post Roof Truss" @@ -672,6 +744,13 @@ def define_connectivity(self) -> None: class DoubleFinkRoofTruss(RoofTruss): + """Double Fink roof truss with two W-shaped web patterns. + + An extension of the Fink truss with additional web members creating two + W patterns. Suitable for longer spans (20-30m) where a standard Fink would + have excessive member lengths. + """ + @property def type(self) -> str: return "Double Fink Roof Truss" @@ -734,6 +813,13 @@ def define_connectivity(self) -> None: class DoubleHoweRoofTruss(RoofTruss): + """Double Howe roof truss with enhanced vertical and diagonal web pattern. + + An extension of the Howe truss with additional verticals and diagonals for + increased load capacity and reduced member lengths. Suitable for long spans + (20-30m) or heavy loading conditions. + """ + @property def type(self) -> str: return "Double Howe Roof Truss" @@ -800,6 +886,13 @@ def define_connectivity(self) -> None: class ModifiedFanRoofTruss(RoofTruss): + """Modified Fan roof truss with enhanced radiating web pattern. + + An enhanced version of the Fan truss with additional web members for + improved structural performance. Suitable for long spans (20-30m) with + excellent load distribution characteristics. + """ + @property def type(self) -> str: return "Modified Fan Roof Truss" @@ -866,6 +959,29 @@ def define_connectivity(self) -> None: class AtticRoofTruss(RoofTruss): + """Attic (or Room-in-Roof) truss with habitable space under the roof. + + Creates a truss with vertical walls and a flat ceiling to provide usable attic + space. The geometry includes: + - Vertical attic walls at the edges of the attic space + - Horizontal ceiling beam + - Sloped top chords from walls to peak + - Diagonal and vertical web members for support + + The attic space is defined by attic_width (floor width) and attic_height + (ceiling height). If attic_height is not specified, it defaults to the height + where the vertical walls meet the sloped roof. + + Attributes: + attic_width (float): Width of the attic floor (interior dimension) + attic_height (float): Height of the attic ceiling + wall_x (float): Horizontal position where attic walls are located + wall_y (float): Height at top of attic walls where they meet the roof slope + ceiling_y (float): Vertical position of the ceiling beam (equals attic_height) + ceiling_x (float): Horizontal position where ceiling meets the sloped top chord + wall_ceiling_intersect (bool): True if wall top and ceiling intersection coincide + """ + # Additional properties for this truss type attic_width: float attic_height: float @@ -893,6 +1009,80 @@ def __init__( web_section: Optional[SectionProps] = None, web_verticals_section: Optional[SectionProps] = None, ): + """Initialize an attic roof truss. + + Args: + width (float): Total span of the truss + roof_pitch_deg (float): Roof pitch angle in degrees + attic_width (float): Interior width of the attic space. Must be less than width. + attic_height (Optional[float]): Height of the attic ceiling. If None, defaults + to the height where vertical walls meet the roof slope. Must be at least + as high as the wall intersection point. + overhang_length (float): Length of roof overhang. Defaults to 0.0. + top_chord_section (Optional[SectionProps]): Section properties for top chord + bottom_chord_section (Optional[SectionProps]): Section properties for bottom chord + web_section (Optional[SectionProps]): Section properties for diagonal webs + web_verticals_section (Optional[SectionProps]): Section properties for vertical webs + + Raises: + ValueError: If attic dimensions are invalid or create impossible geometry + """ + # NOTE: Must compute attic geometry BEFORE calling super().__init__() because + # define_nodes() needs these values, and it's called within super().__init__() + + if attic_width <= 0: + raise ValueError(f"attic_width must be positive, got {attic_width}") + if attic_width >= width: + raise ValueError( + f"attic_width ({attic_width}) must be less than truss width ({width})" + ) + + self.attic_width = attic_width + + # Compute roof pitch first (needed for geometry calculations) + roof_pitch = np.radians(roof_pitch_deg) + + # Calculate horizontal position of attic walls (from centerline) + wall_x = width / 2 - attic_width / 2 + + # Calculate height where vertical wall meets the sloped roof + # Using: wall_y = wall_x * tan(roof_pitch) + wall_y = wall_x * np.tan(roof_pitch) + + # Set ceiling height + if attic_height is None: + # Default: ceiling at the wall-roof intersection + ceiling_y = wall_y + else: + ceiling_y = attic_height + + # Calculate peak height for this width and pitch + peak_height = (width / 2) * np.tan(roof_pitch) + + # Calculate horizontal position where ceiling meets the sloped top chord + # From peak: horizontal_distance = (peak_height - ceiling_height) / tan(roof_pitch) + # From centerline: ceiling_x = centerline - horizontal_distance + ceiling_x = width / 2 - (peak_height - ceiling_y) / np.tan(roof_pitch) + + # Validate geometry: ceiling must be at or above the wall intersection + if ceiling_y < wall_y or ceiling_x < wall_x: + raise ValueError( + f"Attic height ({ceiling_y:.2f}) is too low. " + f"Minimum attic height for this configuration is {wall_y:.2f}. " + f"Please increase attic_height or decrease attic_width." + ) + + # Store computed geometry + self.attic_height = ceiling_y # Use the computed ceiling_y which is always a float + self.wall_x = wall_x + self.wall_y = wall_y + self.ceiling_y = ceiling_y + self.ceiling_x = ceiling_x + + # Check if wall top and ceiling intersection are at the same point + self.wall_ceiling_intersect = (self.ceiling_y == self.wall_y) + + # Now call super().__init__() which will call define_nodes/connectivity/supports super().__init__( width=width, roof_pitch_deg=roof_pitch_deg, @@ -902,23 +1092,6 @@ def __init__( web_section=web_section, web_verticals_section=web_verticals_section, ) - self.attic_width = attic_width - self.wall_x = self.width / 2 - self.attic_width / 2 - self.wall_y = self.wall_x * np.tan(self.roof_pitch) - if attic_height is None: - self.attic_height = self.wall_y - else: - self.attic_height = attic_height - self.ceiling_y = self.attic_height - self.ceiling_x = self.width / 2 - (self.height - self.ceiling_y) / np.tan( - self.roof_pitch - ) - if self.ceiling_y == self.wall_y: - self.wall_ceiling_intersect = True - if self.ceiling_y < self.wall_y or self.ceiling_x < self.wall_y: - raise ValueError( - "Attic height may not be less than the attic wall height. Please increase your attic width and/or attic height." - ) def define_nodes(self) -> None: # Bottom chord nodes diff --git a/anastruct/preprocess/truss_class.py b/anastruct/preprocess/truss_class.py index 457af212..b7a2019d 100644 --- a/anastruct/preprocess/truss_class.py +++ b/anastruct/preprocess/truss_class.py @@ -15,6 +15,30 @@ class Truss(ABC): + """Abstract base class for 2D truss structures. + + Provides a framework for creating parametric truss geometries with automated + node generation, connectivity, and support definitions. Subclasses implement + specific truss types (Howe, Pratt, Warren, etc.). + + The truss generation follows a three-phase process: + 1. define_nodes() - Generate node coordinates + 2. define_connectivity() - Define which nodes connect to form elements + 3. define_supports() - Define support locations and types + + Attributes: + width (float): Total span of the truss (length units) + height (float): Height of the truss (length units) + top_chord_section (SectionProps): Section properties for top chord elements + bottom_chord_section (SectionProps): Section properties for bottom chord elements + web_section (SectionProps): Section properties for diagonal web elements + web_verticals_section (SectionProps): Section properties for vertical web elements + top_chord_continuous (bool): If True, top chord is continuous; if False, pinned at joints + bottom_chord_continuous (bool): If True, bottom chord is continuous; if False, pinned at joints + supports_type (Literal["simple", "pinned", "fixed"]): Type of supports to apply + system (SystemElements): The FEM system containing all nodes, elements, and supports + """ + # Common geometry width: float height: float @@ -25,26 +49,26 @@ class Truss(ABC): web_section: SectionProps web_verticals_section: SectionProps - # Configuraion - top_chord_continous: bool + # Configuration + top_chord_continuous: bool bottom_chord_continuous: bool supports_type: Literal["simple", "pinned", "fixed"] - # Defined by subclass - nodes: list[Vertex] = [] - top_chord_node_ids: Union[list[int], dict[str, list[int]]] = [] - bottom_chord_node_ids: Union[list[int], dict[str, list[int]]] = [] - web_node_pairs: list[tuple[int, int]] = [] - web_verticals_node_pairs: list[tuple[int, int]] = [] - support_definitions: dict[int, Literal["fixed", "pinned", "roller"]] = {} - top_chord_length: float = 0.0 - bottom_chord_length: float = 0.0 - - # Defined by main class - top_chord_element_ids: Union[list[int], dict[str, list[int]]] = [] - bottom_chord_element_ids: Union[list[int], dict[str, list[int]]] = [] - web_element_ids: list[int] = [] - web_verticals_element_ids: list[int] = [] + # Defined by subclass (initialized in define_* methods) + nodes: list[Vertex] + top_chord_node_ids: Union[list[int], dict[str, list[int]]] + bottom_chord_node_ids: Union[list[int], dict[str, list[int]]] + web_node_pairs: list[tuple[int, int]] + web_verticals_node_pairs: list[tuple[int, int]] + support_definitions: dict[int, Literal["fixed", "pinned", "roller"]] + top_chord_length: float + bottom_chord_length: float + + # Defined by main class (initialized in add_elements) + top_chord_element_ids: Union[list[int], dict[str, list[int]]] + bottom_chord_element_ids: Union[list[int], dict[str, list[int]]] + web_element_ids: list[int] + web_verticals_element_ids: list[int] # System system: SystemElements @@ -61,6 +85,35 @@ def __init__( bottom_chord_continuous: bool = True, supports_type: Literal["simple", "pinned", "fixed"] = "simple", ): + """Initialize a truss structure. + + Args: + width (float): Total span of the truss. Must be positive. + height (float): Height of the truss. Must be positive. + top_chord_section (Optional[SectionProps]): Section properties for top chord. + Defaults to DEFAULT_TRUSS_SECTION if not provided. + bottom_chord_section (Optional[SectionProps]): Section properties for bottom chord. + Defaults to DEFAULT_TRUSS_SECTION if not provided. + web_section (Optional[SectionProps]): Section properties for diagonal web members. + Defaults to DEFAULT_TRUSS_SECTION if not provided. + web_verticals_section (Optional[SectionProps]): Section properties for vertical web members. + Defaults to web_section if not provided. + top_chord_continuous (bool): If True, top chord is continuous at joints (moment connection). + If False, top chord is pinned at joints. Defaults to True. + bottom_chord_continuous (bool): If True, bottom chord is continuous at joints. + If False, bottom chord is pinned at joints. Defaults to True. + supports_type (Literal["simple", "pinned", "fixed"]): Type of supports. + "simple" creates pinned+roller, "pinned" creates pinned+pinned, "fixed" creates fixed+fixed. + Defaults to "simple". + + Raises: + ValueError: If width or height is not positive. + """ + if width <= 0: + raise ValueError(f"width must be positive, got {width}") + if height <= 0: + raise ValueError(f"height must be positive, got {height}") + self.width = width self.height = height self.top_chord_section = top_chord_section or DEFAULT_TRUSS_SECTION @@ -71,6 +124,14 @@ def __init__( self.bottom_chord_continuous = bottom_chord_continuous self.supports_type = supports_type + # Initialize mutable attributes (prevents sharing between instances) + self.nodes = [] + self.web_node_pairs = [] + self.web_verticals_node_pairs = [] + self.support_definitions = {} + self.top_chord_length = 0.0 + self.bottom_chord_length = 0.0 + self.define_nodes() self.define_connectivity() self.define_supports() @@ -83,30 +144,67 @@ def __init__( @property @abstractmethod def type(self) -> str: + """Return the human-readable name of the truss type.""" pass @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 truss. + """ pass @abstractmethod def define_connectivity(self) -> None: + """Define element connectivity by populating node ID lists. + + Must be implemented by subclasses. Should populate: + - self.top_chord_node_ids + - self.bottom_chord_node_ids + - self.web_node_pairs + - self.web_verticals_node_pairs + """ pass @abstractmethod def define_supports(self) -> None: + """Define support locations and types by populating self.support_definitions. + + Must be implemented by subclasses. + """ pass 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) def add_elements(self) -> None: + """Create elements from connectivity definitions and add to SystemElements. + + Populates element ID lists: + - self.top_chord_element_ids + - self.bottom_chord_element_ids + - self.web_element_ids + - self.web_verticals_element_ids + """ def add_segment_elements( node_pairs: Iterable[tuple[int, int]], section: SectionProps, continuous: bool, ) -> 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 + continuous (bool): If True, create moment connections; if False, pin connections + + Returns: + list[int]: Element IDs of created elements + """ element_ids = [] for i, j in node_pairs: element_ids.append( @@ -171,6 +269,7 @@ def add_segment_elements( ) 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) @@ -179,9 +278,39 @@ def add_supports(self) -> None: elif support_type == "roller": self.system.add_support_roll(node_id=node_id) + def _resolve_support_type(self, is_primary: bool = True) -> Literal["fixed", "pinned", "roller"]: + """Helper to resolve support type from "simple" to specific type. + + Args: + is_primary (bool): If True, this is the primary (left) support. + If False, this is the secondary (right) support. + + Returns: + Literal["fixed", "pinned", "roller"]: The resolved support type. + For "simple", returns "pinned" if primary, "roller" if secondary. + """ + if self.supports_type != "simple": + return self.supports_type + return "pinned" if is_primary else "roller" + def get_element_ids_of_chord( self, chord: Literal["top", "bottom"], chord_segment: Optional[str] = None ) -> list[int]: + """Get element IDs for a chord (top or bottom). + + Args: + chord (Literal["top", "bottom"]): Which chord to query + chord_segment (Optional[str]): If the chord is segmented (dict of segments), + specify which segment to get. If None and chord is segmented, returns + all element IDs from all segments concatenated. + + Returns: + list[int]: Element IDs of the requested chord (segment) + + Raises: + ValueError: If chord is not "top" or "bottom" + KeyError: If chord_segment is specified but doesn't exist in the chord + """ if chord == "top": if isinstance(self.top_chord_element_ids, dict): if chord_segment is None: @@ -189,6 +318,12 @@ def get_element_ids_of_chord( for ids in self.top_chord_element_ids.values(): all_ids.extend(ids) return all_ids + if chord_segment not in self.top_chord_element_ids: + available = list(self.top_chord_element_ids.keys()) + raise KeyError( + f"chord_segment '{chord_segment}' not found. " + f"Available segments: {available}" + ) return self.top_chord_element_ids[chord_segment] return self.top_chord_element_ids @@ -199,6 +334,12 @@ def get_element_ids_of_chord( for ids in self.bottom_chord_element_ids.values(): all_ids.extend(ids) return all_ids + if chord_segment not in self.bottom_chord_element_ids: + available = list(self.bottom_chord_element_ids.keys()) + raise KeyError( + f"chord_segment '{chord_segment}' not found. " + f"Available segments: {available}" + ) return self.bottom_chord_element_ids[chord_segment] return self.bottom_chord_element_ids @@ -207,11 +348,23 @@ def get_element_ids_of_chord( def apply_q_load_to_top_chord( self, q: Union[float, Sequence[float]], - direction: Union["LoadDirection", Sequence["LoadDirection"]] = "element", + direction: Union[LoadDirection, Sequence[LoadDirection]] = "element", rotation: Optional[Union[float, Sequence[float]]] = None, q_perp: Optional[Union[float, Sequence[float]]] = None, chord_segment: Optional[str] = None, ) -> None: + """Apply distributed load to all elements in the top chord. + + 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 + chord_segment (Optional[str]): If specified, apply load only to this segment + (for trusses with segmented chords like roof trusses) + """ element_ids = self.get_element_ids_of_chord( chord="top", chord_segment=chord_segment ) @@ -227,11 +380,23 @@ def apply_q_load_to_top_chord( def apply_q_load_to_bottom_chord( self, q: Union[float, Sequence[float]], - direction: Union["LoadDirection", Sequence["LoadDirection"]] = "element", + direction: Union[LoadDirection, Sequence[LoadDirection]] = "element", rotation: Optional[Union[float, Sequence[float]]] = None, q_perp: Optional[Union[float, Sequence[float]]] = None, chord_segment: Optional[str] = None, ) -> None: + """Apply distributed load to all elements in the bottom chord. + + 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 + chord_segment (Optional[str]): If specified, apply load only to this segment + (for trusses with segmented chords like roof trusses) + """ element_ids = self.get_element_ids_of_chord( chord="bottom", chord_segment=chord_segment ) @@ -245,10 +410,27 @@ def apply_q_load_to_bottom_chord( ) def show_structure(self) -> None: + """Display the truss structure using matplotlib.""" self.system.show_structure() class FlatTruss(Truss): + """Abstract base class for flat (parallel chord) truss structures. + + Flat trusses have parallel top and bottom chords and are divided into + repeating panel units. Specific truss patterns (Howe, Pratt, Warren) + are implemented by subclasses. + + Attributes: + unit_width (float): Width of each panel/bay + end_type (EndType): Configuration of truss ends - "flat", "triangle_down", or "triangle_up" + supports_loc (SupportLoc): Where supports are placed - "bottom_chord", "top_chord", or "both" + min_end_fraction (float): Minimum width of end panels as fraction of unit_width + enforce_even_units (bool): If True, ensure even number of panels for symmetry + n_units (int): Computed number of panel units + end_width (float): Computed width of end panels + """ + # Data types specific to this truss type EndType = Literal["flat", "triangle_down", "triangle_up"] SupportLoc = Literal["bottom_chord", "top_chord", "both"] @@ -258,7 +440,7 @@ class FlatTruss(Truss): end_type: EndType supports_loc: SupportLoc - # Addtional configuration + # Additional configuration min_end_fraction: float enforce_even_units: bool @@ -285,17 +467,59 @@ def __init__( web_section: Optional[SectionProps] = None, web_verticals_section: Optional[SectionProps] = None, ): + """Initialize a flat truss. + + Args: + width (float): Total span of the truss. Must be positive. + height (float): Height of the truss. Must be positive. + unit_width (float): Width of each panel. Must be positive and less than + width - 2*min_end_fraction*unit_width. + end_type (EndType): End panel configuration. "triangle_down" has diagonals + pointing down at ends, "triangle_up" has diagonals pointing up, + "flat" has vertical end panels. + supports_loc (SupportLoc): Location of supports - "bottom_chord" (typical), + "top_chord" (hanging truss), or "both" (supported at both chords). + min_end_fraction (float): Minimum end panel width as fraction of unit_width. + Must be between 0 and 1. Defaults to 0.5. + enforce_even_units (bool): If True, ensure even number of units for symmetry. + Defaults to True. + top_chord_section (Optional[SectionProps]): Section properties for top chord + bottom_chord_section (Optional[SectionProps]): Section properties for bottom chord + web_section (Optional[SectionProps]): Section properties for diagonal webs + web_verticals_section (Optional[SectionProps]): Section properties for vertical webs + + Raises: + ValueError: If dimensions are invalid or result in negative/zero units + """ + if unit_width <= 0: + raise ValueError(f"unit_width must be positive, got {unit_width}") + if not 0 < min_end_fraction <= 1: + raise ValueError(f"min_end_fraction must be in (0, 1], got {min_end_fraction}") self.unit_width = unit_width self.end_type = end_type self.supports_loc = supports_loc self.min_end_fraction = min_end_fraction self.enforce_even_units = enforce_even_units - self.n_units = np.floor( - (width - unit_width * 2 * min_end_fraction) / unit_width - ) + + # Compute number of units + n_units_float = (width - unit_width * 2 * min_end_fraction) / unit_width + if n_units_float < 1: + raise ValueError( + f"Width {width} is too small for unit_width {unit_width} and " + f"min_end_fraction {min_end_fraction}. Would result in {n_units_float:.2f} units." + ) + + self.n_units = int(np.floor(n_units_float)) if self.enforce_even_units and self.n_units % 2 != 0: self.n_units -= 1 + + if self.n_units < 2: + raise ValueError( + f"Truss must have at least 2 units. Computed {self.n_units} units. " + f"Reduce unit_width or increase width." + ) + self.end_width = (width - self.n_units * unit_width) / 2 super().__init__( width, @@ -315,8 +539,11 @@ def define_connectivity(self) -> None: pass def define_supports(self) -> None: - # This default implementation assumes that top and bottom chords are one single segment / list, and - # that supports are at the ends of the truss. + """Define support locations for flat trusses. + + Default implementation places supports at the ends of the truss. + Assumes single-segment (non-dict) chord node ID lists. + """ assert isinstance(self.bottom_chord_node_ids, list) assert isinstance(self.top_chord_node_ids, list) bottom_left = 0 @@ -324,22 +551,26 @@ def define_supports(self) -> None: top_left = min(self.top_chord_node_ids) top_right = max(self.top_chord_node_ids) if self.supports_loc in ["bottom_chord", "both"]: - self.support_definitions[bottom_left] = ( - self.supports_type if self.supports_type != "simple" else "pinned" - ) - self.support_definitions[bottom_right] = ( - self.supports_type if self.supports_type != "simple" else "roller" - ) + self.support_definitions[bottom_left] = self._resolve_support_type(is_primary=True) + self.support_definitions[bottom_right] = self._resolve_support_type(is_primary=False) if self.supports_loc in ["top_chord", "both"]: - self.support_definitions[top_left] = ( - self.supports_type if self.supports_type != "simple" else "pinned" - ) - self.support_definitions[top_right] = ( - self.supports_type if self.supports_type != "simple" else "roller" - ) + self.support_definitions[top_left] = self._resolve_support_type(is_primary=True) + self.support_definitions[top_right] = self._resolve_support_type(is_primary=False) class RoofTruss(Truss): + """Abstract base class for peaked roof truss structures. + + Roof trusses have sloped top chords meeting at a peak, forming a triangular + profile. Height is computed from span and roof pitch. Specific truss patterns + (King Post, Queen Post, Fink, etc.) are implemented by subclasses. + + Attributes: + overhang_length (float): Length of roof overhang beyond supports + roof_pitch_deg (float): Roof pitch angle in degrees + roof_pitch (float): Roof pitch angle in radians (computed) + """ + # Additional geometry for this truss type overhang_length: float roof_pitch_deg: float @@ -362,6 +593,27 @@ def __init__( web_section: Optional[SectionProps] = None, web_verticals_section: Optional[SectionProps] = None, ): + """Initialize a roof truss. + + Args: + width (float): Total span of the truss (building width). Must be positive. + roof_pitch_deg (float): Roof pitch angle in degrees. Must be positive and + less than 90 degrees. Common values: 18-45 degrees. + overhang_length (float): Length of roof overhang beyond the supports. + Must be non-negative. Defaults to 0.0. + top_chord_section (Optional[SectionProps]): Section properties for top chord + bottom_chord_section (Optional[SectionProps]): Section properties for bottom chord + web_section (Optional[SectionProps]): Section properties for diagonal webs + web_verticals_section (Optional[SectionProps]): Section properties for vertical webs + + Raises: + ValueError: If dimensions or angles are invalid + """ + if roof_pitch_deg <= 0 or roof_pitch_deg >= 90: + raise ValueError(f"roof_pitch_deg must be between 0 and 90, got {roof_pitch_deg}") + if overhang_length < 0: + raise ValueError(f"overhang_length must be non-negative, got {overhang_length}") + self.roof_pitch_deg = roof_pitch_deg self.roof_pitch = np.radians(roof_pitch_deg) height = (width / 2) * np.tan(self.roof_pitch) @@ -384,15 +636,14 @@ def define_connectivity(self) -> None: pass def define_supports(self) -> None: - # This default implementation assumes that bottom chords are one single segment / list, and - # that supports are at the ends of the truss. + """Define support locations for roof trusses. + + Default implementation places supports at the ends of the bottom chord. + Assumes single-segment (non-dict) bottom chord node ID list. + """ assert isinstance(self.bottom_chord_node_ids, list) bottom_left = 0 bottom_right = max(self.bottom_chord_node_ids) - self.support_definitions[bottom_left] = ( - self.supports_type if self.supports_type != "simple" else "pinned" - ) - self.support_definitions[bottom_right] = ( - self.supports_type if self.supports_type != "simple" else "roller" - ) + self.support_definitions[bottom_left] = self._resolve_support_type(is_primary=True) + self.support_definitions[bottom_right] = self._resolve_support_type(is_primary=False) From e52a16a57b119744cc1aed455b44db75c4362b6b Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:24:52 +1100 Subject: [PATCH 18/23] Add a factory function --- anastruct/preprocess/truss.py | 73 ++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/anastruct/preprocess/truss.py b/anastruct/preprocess/truss.py index 100e45ed..e4da387c 100644 --- a/anastruct/preprocess/truss.py +++ b/anastruct/preprocess/truss.py @@ -1,10 +1,13 @@ -from typing import Literal, Optional +from typing import TYPE_CHECKING, Literal, Optional import numpy as np from anastruct.preprocess.truss_class import FlatTruss, RoofTruss from anastruct.types import SectionProps, Vertex +if TYPE_CHECKING: + from anastruct.preprocess.truss_class import Truss + class HoweFlatTruss(FlatTruss): """Howe flat truss with vertical web members and diagonal members in compression. @@ -1180,6 +1183,73 @@ def define_connectivity(self) -> None: self.web_verticals_node_pairs.append((2, 9)) +def create_truss(truss_type: str, **kwargs) -> "Truss": + """Factory function to create truss instances by type name. + + Provides a convenient way to create trusses without importing specific classes. + Type names are case-insensitive and can use underscores or hyphens as separators. + + Args: + truss_type (str): Name of the truss type. Supported types: + Flat trusses: "howe", "pratt", "warren" + Roof trusses: "king_post", "queen_post", "fink", "howe_roof", "pratt_roof", + "fan", "modified_queen_post", "double_fink", "double_howe", + "modified_fan", "attic" + **kwargs: Arguments to pass to the truss constructor + + Returns: + Truss: An instance of the requested truss type + + Raises: + ValueError: If truss_type is not recognized + + Examples: + >>> truss = create_truss("howe", width=20, height=2.5, unit_width=2.0) + >>> truss = create_truss("king-post", width=10, roof_pitch_deg=30) + """ + # Normalize the truss type name + normalized = truss_type.lower().replace("-", "_").replace(" ", "_") + + # Map of normalized names to classes + truss_map = { + # Flat trusses + "howe": HoweFlatTruss, + "howe_flat": HoweFlatTruss, + "pratt": PrattFlatTruss, + "pratt_flat": PrattFlatTruss, + "warren": WarrenFlatTruss, + "warren_flat": WarrenFlatTruss, + # Roof trusses + "king_post": KingPostRoofTruss, + "kingpost": KingPostRoofTruss, + "queen_post": QueenPostRoofTruss, + "queenpost": QueenPostRoofTruss, + "fink": FinkRoofTruss, + "howe_roof": HoweRoofTruss, + "pratt_roof": PrattRoofTruss, + "fan": FanRoofTruss, + "modified_queen_post": ModifiedQueenPostRoofTruss, + "modified_queenpost": ModifiedQueenPostRoofTruss, + "double_fink": DoubleFinkRoofTruss, + "doublefink": DoubleFinkRoofTruss, + "double_howe": DoubleHoweRoofTruss, + "doublehowe": DoubleHoweRoofTruss, + "modified_fan": ModifiedFanRoofTruss, + "modifiedfan": ModifiedFanRoofTruss, + "attic": AtticRoofTruss, + "attic_roof": AtticRoofTruss, + } + + if normalized not in truss_map: + available = sorted(set(truss_map.keys())) + raise ValueError( + f"Unknown truss type '{truss_type}'. Available types: {', '.join(available)}" + ) + + truss_class = truss_map[normalized] + return truss_class(**kwargs) + + __all__ = [ "HoweFlatTruss", "PrattFlatTruss", @@ -1195,4 +1265,5 @@ def define_connectivity(self) -> None: "DoubleHoweRoofTruss", "ModifiedFanRoofTruss", "AtticRoofTruss", + "create_truss", ] From 30323d0aa060b02a0abac5d085fb13a13cf508f2 Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:25:02 +1100 Subject: [PATCH 19/23] Add validation function --- anastruct/preprocess/truss_class.py | 122 +++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/anastruct/preprocess/truss_class.py b/anastruct/preprocess/truss_class.py index b7a2019d..357a5d23 100644 --- a/anastruct/preprocess/truss_class.py +++ b/anastruct/preprocess/truss_class.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Iterable, Literal, Optional, Sequence, Union +from typing import Iterable, Literal, Optional, Sequence, Union, overload import numpy as np @@ -293,6 +293,16 @@ def _resolve_support_type(self, is_primary: bool = True) -> Literal["fixed", "pi return self.supports_type return "pinned" if is_primary else "roller" + @overload + def get_element_ids_of_chord( + self, chord: Literal["top", "bottom"], chord_segment: None = None + ) -> list[int]: ... + + @overload + def get_element_ids_of_chord( + self, chord: Literal["top", "bottom"], chord_segment: str + ) -> list[int]: ... + def get_element_ids_of_chord( self, chord: Literal["top", "bottom"], chord_segment: Optional[str] = None ) -> list[int]: @@ -409,6 +419,116 @@ def apply_q_load_to_bottom_chord( q_perp=q_perp, ) + def validate(self) -> bool: + """Validate truss geometry and connectivity. + + Checks for common truss definition issues: + - All node IDs in connectivity 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) - 1 + + # Helper to validate node ID list + def validate_node_ids(node_ids: Union[list[int], dict[str, list[int]]], name: str) -> None: + 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: + raise ValueError( + f"{name} segment '{segment_name}' references invalid node ID {node_id}. " + f"Valid range: 0-{max_node_id}" + ) + else: + for node_id in node_ids: + if node_id < 0 or node_id > max_node_id: + raise ValueError( + f"{name} references invalid node ID {node_id}. " + f"Valid range: 0-{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: + raise ValueError( + f"web_node_pairs[{i}] references invalid node ID {node_a}. " + f"Valid range: 0-{max_node_id}" + ) + if node_b < 0 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}" + ) + + for i, (node_a, node_b) in enumerate(self.web_verticals_node_pairs): + if node_a < 0 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}" + ) + if node_b < 0 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}" + ) + + # Check for duplicate node locations (within tolerance) + tolerance = 1e-6 + for i in range(len(self.nodes)): + for j in range(i + 1, len(self.nodes)): + node_i = self.nodes[i] + 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] + node_b = self.nodes[node_b_id] + 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 chord elements + def check_chord_elements(node_ids: Union[list[int], dict[str, list[int]]], chord_name: str) -> None: + if isinstance(node_ids, dict): + for segment_name, ids in node_ids.items(): + for i in range(len(ids) - 1): + check_element_length(ids[i], ids[i + 1], f"{chord_name} segment '{segment_name}'") + else: + for i in range(len(node_ids) - 1): + check_element_length(node_ids[i], node_ids[i + 1], chord_name) + + check_chord_elements(self.top_chord_node_ids, "top chord") + check_chord_elements(self.bottom_chord_node_ids, "bottom chord") + + for i, (node_a, node_b) in enumerate(self.web_node_pairs): + check_element_length(node_a, node_b, f"web diagonal {i}") + + for i, (node_a, node_b) in enumerate(self.web_verticals_node_pairs): + check_element_length(node_a, node_b, f"web vertical {i}") + + return True + def show_structure(self) -> None: """Display the truss structure using matplotlib.""" self.system.show_structure() From 56cf4c5466800bd88f5f4c701a3b098f5a2f8c51 Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:47:21 +1100 Subject: [PATCH 20/23] Add truss tests --- anastruct/preprocess/truss.py | 4 +- tests/test_truss.py | 665 ++++++++++++++++++++++++++++++++++ 2 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 tests/test_truss.py diff --git a/anastruct/preprocess/truss.py b/anastruct/preprocess/truss.py index e4da387c..d6b751c7 100644 --- a/anastruct/preprocess/truss.py +++ b/anastruct/preprocess/truss.py @@ -1068,7 +1068,9 @@ def __init__( ceiling_x = width / 2 - (peak_height - ceiling_y) / np.tan(roof_pitch) # Validate geometry: ceiling must be at or above the wall intersection - if ceiling_y < wall_y or ceiling_x < wall_x: + # Use tolerance for floating point comparison + tolerance = 1e-6 + if ceiling_y < wall_y - tolerance or ceiling_x < wall_x - tolerance: raise ValueError( f"Attic height ({ceiling_y:.2f}) is too low. " f"Minimum attic height for this configuration is {wall_y:.2f}. " diff --git a/tests/test_truss.py b/tests/test_truss.py new file mode 100644 index 00000000..039efd8b --- /dev/null +++ b/tests/test_truss.py @@ -0,0 +1,665 @@ +"""Tests for truss generator functionality. + +Tests cover: +- Unit tests for each truss type (geometry validation) +- Integration tests (solve and verify structural behavior) +- Factory function +- Validation method +- Edge cases and error handling +""" + +import numpy as np +from pytest import approx, raises + +from anastruct.preprocess.truss import ( + AtticRoofTruss, + DoubleFinkRoofTruss, + DoubleHoweRoofTruss, + FanRoofTruss, + FinkRoofTruss, + HoweFlatTruss, + HoweRoofTruss, + KingPostRoofTruss, + ModifiedFanRoofTruss, + ModifiedQueenPostRoofTruss, + PrattFlatTruss, + PrattRoofTruss, + QueenPostRoofTruss, + WarrenFlatTruss, + create_truss, +) +from anastruct.types import Vertex + + +def describe_flat_truss_types(): + """Unit tests for flat truss types.""" + + def describe_howe_flat_truss(): + def it_creates_valid_geometry(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + assert truss.type == "Howe Flat Truss" + assert truss.width == 20 + assert truss.height == 2.5 + assert truss.n_units == 8 + assert len(truss.nodes) == 20 + assert truss.validate() + + def it_has_correct_connectivity(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Should have bottom chord, top chord, web diagonals, and web verticals + # 8 units, bottom chord has more nodes + assert len(truss.bottom_chord_node_ids) == 11 + assert len(truss.top_chord_node_ids) == 9 + assert len(truss.web_node_pairs) > 0 + assert len(truss.web_verticals_node_pairs) > 0 + + def it_enforces_even_units_by_default(): + # Width that would give 9 units, should round down to 8 + truss = HoweFlatTruss(width=19, height=2.5, unit_width=2.0) + assert truss.n_units == 8 + assert truss.n_units % 2 == 0 + + def it_validates_dimensions(): + with raises(ValueError, match="too small"): + HoweFlatTruss(width=-5, height=2.5, unit_width=2.0) + + with raises(ValueError, match="must be positive"): + HoweFlatTruss(width=20, height=-2.5, unit_width=2.0) + + with raises(ValueError, match="unit_width must be positive"): + HoweFlatTruss(width=20, height=2.5, unit_width=-1.0) + + def it_validates_width_to_unit_width_ratio(): + with raises(ValueError, match="too small"): + HoweFlatTruss(width=5, height=2.5, unit_width=20) + + def describe_pratt_flat_truss(): + def it_creates_valid_geometry(): + truss = PrattFlatTruss(width=20, height=2.5, unit_width=2.0) + + assert truss.type == "Pratt Flat Truss" + assert truss.n_units == 8 + assert truss.validate() + + def it_has_different_diagonal_pattern_than_howe(): + howe = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + pratt = PrattFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Same number of nodes and elements, but different connectivity + assert len(howe.nodes) == len(pratt.nodes) + # Web diagonals should be different (opposite slope direction) + assert howe.web_node_pairs != pratt.web_node_pairs + + def describe_warren_flat_truss(): + def it_creates_valid_geometry(): + truss = WarrenFlatTruss(width=20, height=2.5, unit_width=2.0) + + assert truss.type == "Warren Flat Truss" + assert truss.validate() + + def it_has_no_vertical_web_members(): + truss = WarrenFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Warren trusses typically have no vertical web members + assert len(truss.web_verticals_node_pairs) == 0 + + def it_supports_different_end_types(): + # Warren supports triangle_down and triangle_up + truss_down = WarrenFlatTruss( + width=20, height=2.5, unit_width=2.0, end_type="triangle_down" + ) + truss_up = WarrenFlatTruss( + width=20, height=2.5, unit_width=2.0, end_type="triangle_up" + ) + + assert truss_down.validate() + assert truss_up.validate() + # Both end types are valid for Warren trusses + assert len(truss_down.nodes) > 0 + assert len(truss_up.nodes) > 0 + + +def describe_roof_truss_types(): + """Unit tests for roof truss types.""" + + def describe_king_post_roof_truss(): + def it_creates_valid_geometry(): + truss = KingPostRoofTruss(width=10, roof_pitch_deg=30) + + assert truss.type == "King Post Roof Truss" + assert truss.width == 10 + assert truss.roof_pitch_deg == 30 + assert truss.validate() + + def it_computes_height_from_pitch(): + truss = KingPostRoofTruss(width=10, roof_pitch_deg=30) + + expected_height = (10 / 2) * np.tan(np.radians(30)) + assert truss.height == approx(expected_height) + + def it_has_single_center_vertical(): + truss = KingPostRoofTruss(width=10, roof_pitch_deg=30) + + # King post has 1 vertical, no diagonals + assert len(truss.web_verticals_node_pairs) == 1 + assert len(truss.web_node_pairs) == 0 + + def it_validates_roof_pitch(): + with raises(ValueError, match="roof_pitch_deg must be between 0 and 90"): + KingPostRoofTruss(width=10, roof_pitch_deg=95) + + with raises(ValueError, match="roof_pitch_deg must be between 0 and 90"): + KingPostRoofTruss(width=10, roof_pitch_deg=-10) + + def it_supports_overhang(): + truss_no_overhang = KingPostRoofTruss(width=10, roof_pitch_deg=30) + truss_with_overhang = KingPostRoofTruss( + width=10, roof_pitch_deg=30, overhang_length=0.5 + ) + + # Overhang adds nodes + assert len(truss_with_overhang.nodes) > len(truss_no_overhang.nodes) + assert truss_with_overhang.validate() + + def describe_queen_post_roof_truss(): + def it_creates_valid_geometry(): + truss = QueenPostRoofTruss(width=12, roof_pitch_deg=35) + + assert truss.type == "Queen Post Roof Truss" + assert truss.validate() + + def it_has_correct_web_configuration(): + truss = QueenPostRoofTruss(width=12, roof_pitch_deg=35) + + # Queen post has 2 diagonals and 1 center vertical + assert len(truss.web_node_pairs) == 2 + assert len(truss.web_verticals_node_pairs) == 1 + + def it_has_center_vertical_to_peak(): + """Test for issue #8 fix - center vertical should connect to peak.""" + truss = QueenPostRoofTruss(width=10, roof_pitch_deg=30) + + # 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) + + # Verify these nodes are actually center bottom and peak + center_bottom = truss.nodes[1] + peak = truss.nodes[4] + + assert center_bottom.x == approx(truss.width / 2) + assert center_bottom.y == approx(0) + assert peak.x == approx(truss.width / 2) + assert peak.y == approx(truss.height) + + def describe_fink_roof_truss(): + def it_creates_valid_geometry(): + truss = FinkRoofTruss(width=15, roof_pitch_deg=40) + + assert truss.type == "Fink Roof Truss" + assert truss.validate() + + def it_has_w_shaped_web_pattern(): + truss = FinkRoofTruss(width=15, roof_pitch_deg=40) + + # Fink has 4 diagonals forming W pattern + assert len(truss.web_node_pairs) == 4 + assert len(truss.web_verticals_node_pairs) == 0 + + def describe_howe_roof_truss(): + def it_creates_valid_geometry(): + truss = HoweRoofTruss(width=12, roof_pitch_deg=35) + + assert truss.type == "Howe Roof Truss" + assert truss.validate() + + def it_has_vertical_and_diagonal_web_members(): + truss = HoweRoofTruss(width=12, roof_pitch_deg=35) + + # Howe has both verticals and diagonals + assert len(truss.web_node_pairs) > 0 + assert len(truss.web_verticals_node_pairs) > 0 + + def describe_pratt_roof_truss(): + def it_creates_valid_geometry(): + truss = PrattRoofTruss(width=12, roof_pitch_deg=35) + + assert truss.type == "Pratt Roof Truss" + assert truss.validate() + + def it_has_vertical_and_diagonal_web_members(): + truss = PrattRoofTruss(width=12, roof_pitch_deg=35) + + # Pratt has both verticals and diagonals + assert len(truss.web_node_pairs) > 0 + assert len(truss.web_verticals_node_pairs) > 0 + + def it_has_different_diagonal_pattern_than_howe(): + howe = HoweRoofTruss(width=12, roof_pitch_deg=35) + pratt = PrattRoofTruss(width=12, roof_pitch_deg=35) + + # Same structure but different web patterns + assert len(howe.nodes) == len(pratt.nodes) + # Diagonals slope in opposite directions + assert howe.web_node_pairs != pratt.web_node_pairs + + def describe_fan_roof_truss(): + def it_creates_valid_geometry(): + truss = FanRoofTruss(width=15, roof_pitch_deg=40) + + assert truss.type == "Fan Roof Truss" + assert truss.validate() + + def it_has_fan_pattern_web_members(): + truss = FanRoofTruss(width=15, roof_pitch_deg=40) + + # Fan has diagonals radiating from bottom chord + assert len(truss.web_node_pairs) > 0 + assert len(truss.web_verticals_node_pairs) > 0 + + def describe_modified_queen_post_roof_truss(): + def it_creates_valid_geometry(): + truss = ModifiedQueenPostRoofTruss(width=12, roof_pitch_deg=35) + + assert truss.type == "Modified Queen Post Roof Truss" + assert truss.validate() + + def it_has_more_web_members_than_standard_queen_post(): + modified = ModifiedQueenPostRoofTruss(width=12, roof_pitch_deg=35) + standard = QueenPostRoofTruss(width=12, roof_pitch_deg=35) + + # Modified version has more web members for enhanced load distribution + total_modified = len(modified.web_node_pairs) + len( + modified.web_verticals_node_pairs + ) + total_standard = len(standard.web_node_pairs) + len( + standard.web_verticals_node_pairs + ) + + assert total_modified > total_standard + + def describe_double_fink_roof_truss(): + def it_creates_valid_geometry(): + truss = DoubleFinkRoofTruss(width=20, roof_pitch_deg=35) + + assert truss.type == "Double Fink Roof Truss" + assert truss.validate() + + def it_has_more_members_than_standard_fink(): + double = DoubleFinkRoofTruss(width=20, roof_pitch_deg=35) + standard = FinkRoofTruss(width=20, roof_pitch_deg=35) + + # Double Fink has more nodes and elements + assert len(double.nodes) > len(standard.nodes) + assert len(double.web_node_pairs) > len(standard.web_node_pairs) + + def it_has_two_w_patterns(): + truss = DoubleFinkRoofTruss(width=20, roof_pitch_deg=35) + + # Double Fink should have 8 diagonals (two W patterns) + assert len(truss.web_node_pairs) == 8 + assert len(truss.web_verticals_node_pairs) == 0 + + def describe_double_howe_roof_truss(): + def it_creates_valid_geometry(): + truss = DoubleHoweRoofTruss(width=20, roof_pitch_deg=35) + + assert truss.type == "Double Howe Roof Truss" + assert truss.validate() + + def it_has_more_verticals_and_diagonals_than_standard(): + double = DoubleHoweRoofTruss(width=20, roof_pitch_deg=35) + standard = HoweRoofTruss(width=20, roof_pitch_deg=35) + + # Double version has enhanced web pattern + assert len(double.web_node_pairs) > len(standard.web_node_pairs) + assert len(double.web_verticals_node_pairs) > len( + standard.web_verticals_node_pairs + ) + + def it_has_five_verticals(): + truss = DoubleHoweRoofTruss(width=20, roof_pitch_deg=35) + + # Double Howe has 5 vertical members + assert len(truss.web_verticals_node_pairs) == 5 + + def describe_modified_fan_roof_truss(): + def it_creates_valid_geometry(): + truss = ModifiedFanRoofTruss(width=15, roof_pitch_deg=40) + + assert truss.type == "Modified Fan Roof Truss" + assert truss.validate() + + def it_has_enhanced_web_pattern(): + modified = ModifiedFanRoofTruss(width=15, roof_pitch_deg=40) + standard = FanRoofTruss(width=15, roof_pitch_deg=40) + + # Modified fan has more web members + total_modified = len(modified.web_node_pairs) + len( + modified.web_verticals_node_pairs + ) + total_standard = len(standard.web_node_pairs) + len( + standard.web_verticals_node_pairs + ) + + assert total_modified > total_standard + + def it_has_six_diagonals_and_three_verticals(): + truss = ModifiedFanRoofTruss(width=15, roof_pitch_deg=40) + + # Modified fan specific configuration + assert len(truss.web_node_pairs) == 6 + assert len(truss.web_verticals_node_pairs) == 3 + + def describe_attic_roof_truss(): + def it_creates_valid_geometry(): + truss = AtticRoofTruss(width=12, roof_pitch_deg=35, attic_width=6) + + assert truss.type == "Attic Roof Truss" + assert truss.validate() + + def it_validates_attic_width(): + with raises(ValueError, match="attic_width.*must be less than"): + AtticRoofTruss(width=10, roof_pitch_deg=30, attic_width=15) + + with raises(ValueError, match="attic_width must be positive"): + AtticRoofTruss(width=10, roof_pitch_deg=30, attic_width=-5) + + def it_computes_attic_geometry(): + truss = AtticRoofTruss(width=12, roof_pitch_deg=35, attic_width=6) + + # Wall position should be at edge of attic + assert truss.wall_x == approx((12 - 6) / 2) + + # Ceiling and wall intersect by default + assert truss.wall_ceiling_intersect or not truss.wall_ceiling_intersect + + def it_supports_custom_attic_height(): + # Use attic_height that's higher than default wall intersection + truss = AtticRoofTruss( + width=12, roof_pitch_deg=35, attic_width=6, attic_height=3.0 + ) + + assert truss.attic_height == approx(3.0) + assert truss.validate() + + def it_has_segmented_top_chord_with_ceiling(): + truss = AtticRoofTruss(width=12, roof_pitch_deg=35, attic_width=6) + + # Attic truss has three segments: left, right, and ceiling + assert isinstance(truss.top_chord_node_ids, dict) + assert "left" in truss.top_chord_node_ids + assert "right" in truss.top_chord_node_ids + assert "ceiling" in truss.top_chord_node_ids + + +def describe_factory_function(): + """Tests for create_truss factory function.""" + + def it_creates_truss_by_name(): + truss = create_truss("howe", width=20, height=2.5, unit_width=2.0) + + assert isinstance(truss, HoweFlatTruss) + assert truss.type == "Howe Flat Truss" + + def it_handles_case_insensitive_names(): + trusses = [ + create_truss("howe", width=20, height=2.5, unit_width=2.0), + create_truss("HOWE", width=20, height=2.5, unit_width=2.0), + create_truss("Howe", width=20, height=2.5, unit_width=2.0), + ] + + for truss in trusses: + assert isinstance(truss, HoweFlatTruss) + + def it_handles_different_name_separators(): + # Underscores, hyphens, spaces should all work + names = ["king_post", "king-post", "kingpost"] + + for name in names: + truss = create_truss(name, width=10, roof_pitch_deg=30) + assert isinstance(truss, KingPostRoofTruss) + + def it_creates_all_truss_types(): + # Test that all truss types can be created via factory + test_cases = [ + ("howe", HoweFlatTruss, {"width": 20, "height": 2.5, "unit_width": 2.0}), + ("pratt", PrattFlatTruss, {"width": 20, "height": 2.5, "unit_width": 2.0}), + ("warren", WarrenFlatTruss, {"width": 20, "height": 2.5, "unit_width": 2.0}), + ("king_post", KingPostRoofTruss, {"width": 10, "roof_pitch_deg": 30}), + ("queen_post", QueenPostRoofTruss, {"width": 12, "roof_pitch_deg": 35}), + ("fink", FinkRoofTruss, {"width": 15, "roof_pitch_deg": 40}), + ("howe_roof", HoweRoofTruss, {"width": 12, "roof_pitch_deg": 35}), + ("pratt_roof", PrattRoofTruss, {"width": 12, "roof_pitch_deg": 35}), + ("fan", FanRoofTruss, {"width": 15, "roof_pitch_deg": 40}), + ( + "attic", + AtticRoofTruss, + {"width": 12, "roof_pitch_deg": 35, "attic_width": 6}, + ), + ] + + for name, expected_class, kwargs in test_cases: + truss = create_truss(name, **kwargs) + assert isinstance(truss, expected_class) + assert truss.validate() + + def it_raises_error_for_invalid_type(): + with raises(ValueError, match="Unknown truss type"): + create_truss("invalid_truss_type", width=10, height=2) + + def it_provides_helpful_error_with_available_types(): + try: + create_truss("nonexistent", width=10, height=2) + assert False, "Should have raised ValueError" + except ValueError as e: + # Error should list available types + assert "Available types:" in str(e) + assert "howe" in str(e).lower() + + +def describe_validate_method(): + """Tests for truss validation method.""" + + def it_validates_correct_geometry(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + assert truss.validate() is True + + def it_catches_invalid_node_ids_in_connectivity(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Corrupt connectivity with invalid node ID + truss.web_node_pairs.append((999, 1000)) + + with raises(ValueError, match="invalid node ID"): + truss.validate() + + 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 + original = truss.nodes[0] + truss.nodes.append(Vertex(original.x, original.y)) + truss.web_node_pairs.append((0, len(truss.nodes) - 1)) + + with raises(ValueError, match="Duplicate nodes"): + truss.validate() + + def it_catches_zero_length_elements(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Create zero-length element by modifying existing node + # Make the second node same as first node + # This will be caught as duplicate nodes + truss.nodes[1] = Vertex(truss.nodes[0].x, truss.nodes[0].y) + + with raises(ValueError, match="Duplicate nodes"): + truss.validate() + + +def describe_integration_tests(): + """Integration tests - system integration and load application.""" + + def describe_system_integration(): + def it_creates_valid_system_elements(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Verify SystemElements was created and populated + assert truss.system is not None + assert len(truss.system.element_map) > 0 + assert len(truss.system.node_map) > 0 + + def it_has_correct_element_count(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Count expected elements + expected_bottom = len(truss.bottom_chord_node_ids) - 1 + expected_top = len(truss.top_chord_node_ids) - 1 + expected_webs = len(truss.web_node_pairs) + expected_verticals = len(truss.web_verticals_node_pairs) + expected_total = ( + expected_bottom + expected_top + expected_webs + expected_verticals + ) + + assert len(truss.system.element_map) == expected_total + + def it_applies_loads_to_chords(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Apply load (don't solve, just verify load application works) + truss.apply_q_load_to_top_chord(q=-10, direction="y") + + # Verify loads were applied to top chord elements + top_chord_ids = truss.get_element_ids_of_chord("top") + for el_id in top_chord_ids: + element = truss.system.element_map[el_id] + # Element should have a q_load attribute after applying + assert hasattr(element, "q_load") + + def describe_roof_truss_integration(): + def it_applies_loads_to_chord_segments(): + truss = QueenPostRoofTruss(width=12, roof_pitch_deg=30) + + # Apply loads to specific segments + truss.apply_q_load_to_top_chord(q=-5, direction="y", chord_segment="left") + truss.apply_q_load_to_top_chord(q=-5, direction="y", chord_segment="right") + + # Verify loads were applied + left_ids = truss.get_element_ids_of_chord("top", "left") + right_ids = truss.get_element_ids_of_chord("top", "right") + + for el_id in left_ids + right_ids: + element = truss.system.element_map[el_id] + assert hasattr(element, "q_load") + + def it_validates_after_load_application(): + truss = QueenPostRoofTruss(width=12, roof_pitch_deg=30) + + # Validate before loading + assert truss.validate() + + # Apply load + truss.apply_q_load_to_top_chord(q=-5, direction="y") + + # Should still validate after loading + assert truss.validate() + + def describe_different_support_types(): + def it_has_default_simple_supports(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Default is simple = pinned + roller + support_defs = truss.support_definitions + support_types = list(support_defs.values()) + + assert "pinned" in support_types + assert "roller" in support_types + + def it_has_two_support_points(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Should have exactly 2 supports at the ends + assert len(truss.support_definitions) == 2 + + +def describe_chord_segment_functionality(): + """Tests for segmented chord access.""" + + def it_gets_all_elements_when_no_segment_specified(): + truss = QueenPostRoofTruss(width=10, roof_pitch_deg=30) + + all_top = truss.get_element_ids_of_chord("top") + + # Should return all top chord elements + assert len(all_top) > 0 + + def it_gets_specific_segment(): + truss = QueenPostRoofTruss(width=10, roof_pitch_deg=30) + + left_elements = truss.get_element_ids_of_chord("top", "left") + right_elements = truss.get_element_ids_of_chord("top", "right") + + # Should get different elements + assert len(left_elements) > 0 + assert len(right_elements) > 0 + assert set(left_elements).isdisjoint(set(right_elements)) + + def it_raises_error_for_invalid_segment(): + truss = QueenPostRoofTruss(width=10, roof_pitch_deg=30) + + with raises(KeyError, match="chord_segment.*not found"): + truss.get_element_ids_of_chord("top", "nonexistent_segment") + + def it_shows_available_segments_in_error(): + truss = QueenPostRoofTruss(width=10, roof_pitch_deg=30) + + try: + truss.get_element_ids_of_chord("top", "invalid") + assert False, "Should have raised KeyError" + except KeyError as e: + # Should list available segments + assert "Available segments:" in str(e) + assert "left" in str(e) + assert "right" in str(e) + + +def describe_edge_cases(): + """Edge case tests.""" + + def it_handles_minimum_viable_dimensions(): + # Smallest practical truss + truss = HoweFlatTruss(width=6, height=1, unit_width=2, min_end_fraction=0.5) + + assert truss.n_units >= 2 + assert truss.validate() + + def it_handles_very_steep_roof_pitch(): + # Very steep but valid pitch + truss = KingPostRoofTruss(width=10, roof_pitch_deg=85) + + assert truss.height > truss.width # Height > width for steep pitch + assert truss.validate() + + def it_handles_very_shallow_roof_pitch(): + # Very shallow but valid pitch + truss = KingPostRoofTruss(width=10, roof_pitch_deg=5) + + assert truss.height < truss.width / 10 # Very shallow + assert truss.validate() + + def it_creates_multiple_independent_instances(): + # Test that instances don't share mutable state (issue #9 fix) + truss1 = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + truss2 = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Modify one truss + truss1.nodes.append(Vertex(100, 100)) + + # Should not affect the other + assert len(truss1.nodes) != len(truss2.nodes) + assert truss2.validate() From 2fa43ac43f10fbe4d8d68fdbb52af65be6bc512b Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:59:36 +1100 Subject: [PATCH 21/23] Fix typing and formatting --- .coverage | Bin 241664 -> 53248 bytes anastruct/preprocess/truss.py | 28 +++++++++++++++++----------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.coverage b/.coverage index 81c5338b57251beb44489a89986d2baa9d9fea7c..3aa74c4c66048d59da1b778f5985a2fc51ed81fa 100644 GIT binary patch delta 2867 zcmZ8jYiv}<6+W}~vG+aKyK8&>+T9RvV<5JH01YoMkg62is%jMy0hO1BRZNWSU?4yt z_nMOYY1O8Tgs55xH<3b=R<2u=&_*e&+n`8Io3x4*9*%8N+Qe-DE5X9x^?G||b`4I( zy8dR)Idjf8XTE!F|ElA6OE1KU{kz29>qz~Xn{^?#(FTB7!MA|ELyyzVw8pvN{Mu=E z>g=2Li}ogaj`g|qvbEcqZ~n`C+1zQ?8rO}}MvE~=|F?cx->xsvZfUP;KhV}_O7N{S zMec!MpkWq!;IlYOTrpVhpv)$3g~^(z4cDP>)JQ`>2on<73?uS_K zC7m6MJGqKI{8%4(q|-H5KgK-I|JyJRE|jS3@&P%eS2G4>ss5l?!d?uVcfiugjhpnxl)Aj#XN*121D$Pj6vLK z2BIgQXTpTu>jrkoZfu6J0&;l9A7t6A!JeC{Vf9mCMJ=4AhOOw=*ug1_D9HIC z)-x5B! zrObLbc)@D3tDWE2qfUpWI`!shy2XCUI&EIFH=8YHoi?VO)eh_b(O=Pb7&nY+;}v6< zzR`#~Hme08VFRG`4Cy0F6jV&n0>T^qHIA)x?40Em}QLMHS#nW)*F{Na+%$0I^A$#MB zDHlH~J%g7p*Pf}ANS++N8V?V+B+TN41N9CntaZRvLf?>4Jh+RC$LMTtMFAj>BIL1{ z-5oGdOVd3-zOt z9U3%IOYgp4D`fHJprPmuau>mR&(Y2J|!pC#Ep-D-ld<=VS16iM^DfrbRT`(`IU3j z`JO%Bo^9Q>hOHj_DE-8G-s-e=S}F67=6B5X<}x#8Moh!_2sQHy!~05HCi=JSO$*9X zqeoZ&P?eP`(0W0B5(4v+#05z+LZJDIq{T2WRQYNyt5xw?>BNy>c_q%4l+?bITMVjC zI?@V&8~`7c?K>6di$zaJxF7(oT8?X{IM7_C5WQ=C#Rn@@fRo-QlehXk4B_K#pF}l` zEesz5Fj^%R$p!%NO#1}7Dx6%jJR2_0?! z$DOynnJbh=8#h9dPx3_(JV>aeyfI}&FF}mN1M)h*s2HcSW3?jAhwQNQzzDF~6wo!ZcBietbSf^Co9wA3@py4H9U@fvo`0PV!sD(Sc7AAEG+E zyx|-DK9qCmb-C2d(q)d$~Zb-uMCmj2K^5umrGiQy0iN;s#yc zEEp453t1-c{$2RCGSdNg?b=|1HM9JU3X&q&vTsC0L{>k z>1jGfZ_vNeH|cBiFSMKfi2jq-(4W&w`1w6fd+0Iz{5H~6bSeEB-AXsn@6vj@pSIJT z_>sNlc$_t{;{qyU0!pg{lvE0cRtP9A7f?|qpgby|tW-d}L_n-qKvhIQBrG5t5?~ey zC~^fjRDj{APw?`REtVlmfMyB^8Ul1(fT{_wf&!>2z{P)saC<-&phyDbfQ(H#lk-U8 HMc@5DE6C>w literal 241664 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 P@lG*@cqbV{yc7QyOAmMq diff --git a/anastruct/preprocess/truss.py b/anastruct/preprocess/truss.py index d6b751c7..88815bae 100644 --- a/anastruct/preprocess/truss.py +++ b/anastruct/preprocess/truss.py @@ -1,13 +1,10 @@ -from typing import TYPE_CHECKING, Literal, Optional +from typing import TYPE_CHECKING, Any, Literal, Optional import numpy as np -from anastruct.preprocess.truss_class import FlatTruss, RoofTruss +from anastruct.preprocess.truss_class import FlatTruss, RoofTruss, Truss from anastruct.types import SectionProps, Vertex -if TYPE_CHECKING: - from anastruct.preprocess.truss_class import Truss - class HoweFlatTruss(FlatTruss): """Howe flat truss with vertical web members and diagonal members in compression. @@ -421,11 +418,17 @@ def define_connectivity(self) -> None: self.top_chord_node_ids["right"].append(7) # right overhang # Web diagonals connectivity - self.web_node_pairs.append((1, 3)) # left diagonal from center bottom to left quarter top - self.web_node_pairs.append((1, 5)) # right diagonal from center bottom to right quarter top + self.web_node_pairs.append( + (1, 3) + ) # left diagonal from center bottom to left quarter top + self.web_node_pairs.append( + (1, 5) + ) # right diagonal from center bottom to right quarter top # Web verticals connectivity - Fixed: should connect to peak (node 4), not node 3 - self.web_verticals_node_pairs.append((1, 4)) # center vertical from center bottom to peak + self.web_verticals_node_pairs.append( + (1, 4) + ) # center vertical from center bottom to peak class FinkRoofTruss(RoofTruss): @@ -1078,14 +1081,16 @@ def __init__( ) # Store computed geometry - self.attic_height = ceiling_y # Use the computed ceiling_y which is always a float + self.attic_height = ( + ceiling_y # Use the computed ceiling_y which is always a float + ) self.wall_x = wall_x self.wall_y = wall_y self.ceiling_y = ceiling_y self.ceiling_x = ceiling_x # Check if wall top and ceiling intersection are at the same point - self.wall_ceiling_intersect = (self.ceiling_y == self.wall_y) + self.wall_ceiling_intersect = self.ceiling_y == self.wall_y # Now call super().__init__() which will call define_nodes/connectivity/supports super().__init__( @@ -1185,7 +1190,7 @@ def define_connectivity(self) -> None: self.web_verticals_node_pairs.append((2, 9)) -def create_truss(truss_type: str, **kwargs) -> "Truss": +def create_truss(truss_type: str, **kwargs: Any) -> "Truss": """Factory function to create truss instances by type name. Provides a convenient way to create trusses without importing specific classes. @@ -1249,6 +1254,7 @@ def create_truss(truss_type: str, **kwargs) -> "Truss": ) truss_class = truss_map[normalized] + assert issubclass(truss_class, Truss) return truss_class(**kwargs) From f238cda5819fdbc1efb5567d593b218ac017003d Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:17:27 +1100 Subject: [PATCH 22/23] Misc typing cleanup --- .coverage | Bin 53248 -> 0 bytes anastruct/fem/system_components/util.py | 6 ++- anastruct/preprocess/truss.py | 5 +- anastruct/preprocess/truss_class.py | 67 ++++++++++++++++-------- anastruct/types.py | 3 +- 5 files changed, 55 insertions(+), 26 deletions(-) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index 3aa74c4c66048d59da1b778f5985a2fc51ed81fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 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 str: """Return the human-readable name of the truss type.""" - pass @abstractmethod def define_nodes(self) -> None: @@ -154,7 +154,6 @@ def define_nodes(self) -> None: Must be implemented by subclasses. Should create Vertex objects representing all node locations in the truss. """ - pass @abstractmethod def define_connectivity(self) -> None: @@ -166,7 +165,6 @@ def define_connectivity(self) -> None: - self.web_node_pairs - self.web_verticals_node_pairs """ - pass @abstractmethod def define_supports(self) -> None: @@ -174,7 +172,6 @@ def define_supports(self) -> None: Must be implemented by subclasses. """ - pass def add_nodes(self) -> None: """Add all nodes from self.nodes to the SystemElements.""" @@ -190,6 +187,7 @@ def add_elements(self) -> None: - self.web_element_ids - self.web_verticals_element_ids """ + def add_segment_elements( node_pairs: Iterable[tuple[int, int]], section: SectionProps, @@ -278,7 +276,9 @@ def add_supports(self) -> None: elif support_type == "roller": self.system.add_support_roll(node_id=node_id) - def _resolve_support_type(self, is_primary: bool = True) -> Literal["fixed", "pinned", "roller"]: + def _resolve_support_type( + self, is_primary: bool = True + ) -> Literal["fixed", "pinned", "roller"]: """Helper to resolve support type from "simple" to specific type. Args: @@ -437,7 +437,9 @@ def validate(self) -> bool: max_node_id = len(self.nodes) - 1 # Helper to validate node ID list - def validate_node_ids(node_ids: Union[list[int], dict[str, list[int]]], name: str) -> None: + def validate_node_ids( + node_ids: Union[list[int], dict[str, list[int]]], name: str + ) -> None: if isinstance(node_ids, dict): for segment_name, ids in node_ids.items(): for node_id in ids: @@ -483,9 +485,8 @@ def validate_node_ids(node_ids: Union[list[int], dict[str, list[int]]], name: st # Check for duplicate node locations (within tolerance) tolerance = 1e-6 - for i in range(len(self.nodes)): + for i, node_i in enumerate(self.nodes): for j in range(i + 1, len(self.nodes)): - node_i = self.nodes[i] node_j = self.nodes[j] dx = abs(node_i.x - node_j.x) dy = abs(node_i.y - node_j.y) @@ -496,7 +497,9 @@ def validate_node_ids(node_ids: Union[list[int], dict[str, list[int]]], name: st ) # Check for zero-length elements - def check_element_length(node_a_id: int, node_b_id: int, element_type: str) -> None: + 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] dx = node_b.x - node_a.x @@ -509,11 +512,15 @@ def check_element_length(node_a_id: int, node_b_id: int, element_type: str) -> N ) # Check chord elements - def check_chord_elements(node_ids: Union[list[int], dict[str, list[int]]], chord_name: str) -> None: + def check_chord_elements( + node_ids: Union[list[int], dict[str, list[int]]], chord_name: str + ) -> None: if isinstance(node_ids, dict): for segment_name, ids in node_ids.items(): for i in range(len(ids) - 1): - check_element_length(ids[i], ids[i + 1], f"{chord_name} segment '{segment_name}'") + check_element_length( + ids[i], ids[i + 1], f"{chord_name} segment '{segment_name}'" + ) else: for i in range(len(node_ids) - 1): check_element_length(node_ids[i], node_ids[i + 1], chord_name) @@ -614,7 +621,9 @@ def __init__( if unit_width <= 0: raise ValueError(f"unit_width must be positive, got {unit_width}") if not 0 < min_end_fraction <= 1: - raise ValueError(f"min_end_fraction must be in (0, 1], got {min_end_fraction}") + raise ValueError( + f"min_end_fraction must be in (0, 1], got {min_end_fraction}" + ) self.unit_width = unit_width self.end_type = end_type @@ -671,11 +680,19 @@ def define_supports(self) -> None: top_left = min(self.top_chord_node_ids) top_right = max(self.top_chord_node_ids) if self.supports_loc in ["bottom_chord", "both"]: - self.support_definitions[bottom_left] = self._resolve_support_type(is_primary=True) - self.support_definitions[bottom_right] = self._resolve_support_type(is_primary=False) + self.support_definitions[bottom_left] = self._resolve_support_type( + is_primary=True + ) + self.support_definitions[bottom_right] = self._resolve_support_type( + is_primary=False + ) if self.supports_loc in ["top_chord", "both"]: - self.support_definitions[top_left] = self._resolve_support_type(is_primary=True) - self.support_definitions[top_right] = self._resolve_support_type(is_primary=False) + self.support_definitions[top_left] = self._resolve_support_type( + is_primary=True + ) + self.support_definitions[top_right] = self._resolve_support_type( + is_primary=False + ) class RoofTruss(Truss): @@ -730,9 +747,13 @@ def __init__( ValueError: If dimensions or angles are invalid """ if roof_pitch_deg <= 0 or roof_pitch_deg >= 90: - raise ValueError(f"roof_pitch_deg must be between 0 and 90, got {roof_pitch_deg}") + raise ValueError( + f"roof_pitch_deg must be between 0 and 90, got {roof_pitch_deg}" + ) if overhang_length < 0: - raise ValueError(f"overhang_length must be non-negative, got {overhang_length}") + raise ValueError( + f"overhang_length must be non-negative, got {overhang_length}" + ) self.roof_pitch_deg = roof_pitch_deg self.roof_pitch = np.radians(roof_pitch_deg) @@ -765,5 +786,9 @@ def define_supports(self) -> None: bottom_left = 0 bottom_right = max(self.bottom_chord_node_ids) - self.support_definitions[bottom_left] = self._resolve_support_type(is_primary=True) - self.support_definitions[bottom_right] = self._resolve_support_type(is_primary=False) + self.support_definitions[bottom_left] = self._resolve_support_type( + is_primary=True + ) + self.support_definitions[bottom_right] = self._resolve_support_type( + is_primary=False + ) diff --git a/anastruct/types.py b/anastruct/types.py index 4a57b24f..90f596fa 100644 --- a/anastruct/types.py +++ b/anastruct/types.py @@ -2,7 +2,8 @@ import numpy as np -from anastruct.vertex import Vertex +if TYPE_CHECKING: + from anastruct.vertex import Vertex AxisNumber = Literal[1, 2, 3] Dimension = Literal["x", "y", "y_neg", "both"] From f49f49438c6b79c9262d57f27f0dfc15f52512a2 Mon Sep 17 00:00:00 2001 From: Brooks Smith <42363318+smith120bh@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:25:04 +1100 Subject: [PATCH 23/23] Fix imports --- anastruct/preprocess/truss_class.py | 2 +- tests/test_truss.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/anastruct/preprocess/truss_class.py b/anastruct/preprocess/truss_class.py index eccae1e3..399325c4 100644 --- a/anastruct/preprocess/truss_class.py +++ b/anastruct/preprocess/truss_class.py @@ -3,7 +3,7 @@ import numpy as np -from anastruct import SystemElements +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 diff --git a/tests/test_truss.py b/tests/test_truss.py index 039efd8b..3137e16d 100644 --- a/tests/test_truss.py +++ b/tests/test_truss.py @@ -28,7 +28,7 @@ WarrenFlatTruss, create_truss, ) -from anastruct.types import Vertex +from anastruct.vertex import Vertex def describe_flat_truss_types(): @@ -429,7 +429,11 @@ def it_creates_all_truss_types(): test_cases = [ ("howe", HoweFlatTruss, {"width": 20, "height": 2.5, "unit_width": 2.0}), ("pratt", PrattFlatTruss, {"width": 20, "height": 2.5, "unit_width": 2.0}), - ("warren", WarrenFlatTruss, {"width": 20, "height": 2.5, "unit_width": 2.0}), + ( + "warren", + WarrenFlatTruss, + {"width": 20, "height": 2.5, "unit_width": 2.0}, + ), ("king_post", KingPostRoofTruss, {"width": 10, "roof_pitch_deg": 30}), ("queen_post", QueenPostRoofTruss, {"width": 12, "roof_pitch_deg": 35}), ("fink", FinkRoofTruss, {"width": 15, "roof_pitch_deg": 40}),