From ecdfd9d1fd9d74836fae5d3023a17aeafb8fb899 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sat, 15 Nov 2025 11:25:19 +0100 Subject: [PATCH 01/23] Add slope to angle and angle to slope conversion functions with tests --- blueprints/math_helpers.py | 36 +++++++++++++++++++++++++++++++++++- tests/test_math_helpers.py | 26 +++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/blueprints/math_helpers.py b/blueprints/math_helpers.py index 4138fff70..19d9fa097 100644 --- a/blueprints/math_helpers.py +++ b/blueprints/math_helpers.py @@ -2,7 +2,7 @@ import numpy as np -from blueprints.type_alias import DEG, DIMENSIONLESS +from blueprints.type_alias import DEG, DIMENSIONLESS, PERCENTAGE from blueprints.validations import raise_if_greater_than_90, raise_if_less_or_equal_to_zero, raise_if_negative @@ -58,3 +58,37 @@ def csc(x: DEG) -> DIMENSIONLESS: raise_if_less_or_equal_to_zero(x=x) raise_if_greater_than_90(x=x) return 1 / np.sin(np.deg2rad(x)) + + +def slope_to_angle(slope: PERCENTAGE) -> DEG: + """Convert a slope (as a percentage) to an angle in degrees. + + Parameters + ---------- + slope : DIMENSIONLESS + Slope as a percentage. + + Returns + ------- + DEG + Angle in degrees. + """ + return np.rad2deg(np.arctan(slope / 100)) + + +def angle_to_slope(angle: DEG) -> PERCENTAGE: + """Convert an angle in degrees to a slope (as a percentage). + + Parameters + ---------- + angle : DEG + Angle in degrees. + + Returns + ------- + PERCENTAGE + Slope as a percentage. + """ + raise_if_negative(angle=angle) + raise_if_greater_than_90(angle=angle) + return np.tan(np.deg2rad(angle)) * 100 diff --git a/tests/test_math_helpers.py b/tests/test_math_helpers.py index 20708dc71..f745088d5 100644 --- a/tests/test_math_helpers.py +++ b/tests/test_math_helpers.py @@ -2,7 +2,7 @@ import pytest -from blueprints.math_helpers import cot, csc, sec +from blueprints.math_helpers import angle_to_slope, cot, csc, sec, slope_to_angle from blueprints.validations import GreaterThan90Error, LessOrEqualToZeroError, NegativeValueError @@ -52,3 +52,27 @@ def test_raise_error_when_invalid_values_are_given(self, x: float) -> None: """Test invalid values.""" with pytest.raises((LessOrEqualToZeroError, GreaterThan90Error)): csc(x) + + +class TestSlopeToAngle: + """Validation for slope to angle conversion.""" + + @pytest.mark.parametrize(("slope", "expected_result"), [(0, 0), (100, 45)]) + def test_evaluation(self, slope: float, expected_result: float) -> None: + """Tests the evaluation of the result.""" + assert slope_to_angle(slope) == pytest.approx(expected_result, rel=1e-4) + + +class TestAngleToSlope: + """Validation for angle to slope conversion.""" + + @pytest.mark.parametrize(("angle", "expected_result"), [(0, 0), (45, 100)]) + def test_evaluation(self, angle: float, expected_result: float) -> None: + """Tests the evaluation of the result.""" + assert angle_to_slope(angle) == pytest.approx(expected_result, rel=1e-4) + + @pytest.mark.parametrize("angle", [-10.0, 100.0]) + def test_raise_error_when_invalid_values_are_given(self, angle: float) -> None: + """Test invalid values.""" + with pytest.raises((NegativeValueError, GreaterThan90Error)): + angle_to_slope(angle) From 1aae0915e2521a0a3983c9a0460aff5472172b7a Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sat, 15 Nov 2025 11:26:49 +0100 Subject: [PATCH 02/23] Add inner slope properties to CircularCorneredCrossSection --- .../cross_section_cornered.py | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/blueprints/structural_sections/cross_section_cornered.py b/blueprints/structural_sections/cross_section_cornered.py index e4404357e..011df979c 100644 --- a/blueprints/structural_sections/cross_section_cornered.py +++ b/blueprints/structural_sections/cross_section_cornered.py @@ -6,8 +6,9 @@ from sectionproperties.pre import Geometry from shapely.geometry import Polygon +from blueprints.math_helpers import slope_to_angle from blueprints.structural_sections._cross_section import CrossSection -from blueprints.type_alias import MM +from blueprints.type_alias import MM, PERCENTAGE from blueprints.validations import raise_if_negative @@ -40,6 +41,14 @@ class CircularCorneredCrossSection(CrossSection): Outer radius of the corner corner_direction : int ↰ = 0, ↱ = 1, ↳ = 2, ↲ = 3 + inner_slope_at_vertical : PERCENTAGE + Slope of the tangent to the inner radius at the vertical section (default 0) + inner_slope_at_horizontal : PERCENTAGE + Slope of the tangent to the inner radius at the horizontal section (default 0) + outer_slope_at_vertical : PERCENTAGE + Slope of the tangent to the outer radius at the vertical section (default 0) + outer_slope_at_horizontal : PERCENTAGE + Slope of the tangent to the outer radius at the horizontal section (default 0) x : MM x-coordinate of the center of the inner_radius (default 0) y : MM @@ -52,6 +61,10 @@ class CircularCorneredCrossSection(CrossSection): thickness_horizontal: MM inner_radius: MM outer_radius: MM + inner_slope_at_vertical: PERCENTAGE = 0 + inner_slope_at_horizontal: PERCENTAGE = 0 + outer_slope_at_vertical: PERCENTAGE = 0 + outer_slope_at_horizontal: PERCENTAGE = 0 x: MM = 0 y: MM = 0 corner_direction: int = 0 # 0 = ↰, 1 = ↱, 2 = ↳, 3 = ↲ @@ -64,6 +77,10 @@ def __post_init__(self) -> None: thickness_horizontal=self.thickness_horizontal, inner_radius=self.inner_radius, outer_radius=self.outer_radius, + inner_slope_at_vertical=self.inner_slope_at_vertical, + inner_slope_at_horizontal=self.inner_slope_at_horizontal, + outer_slope_at_vertical=self.outer_slope_at_vertical, + outer_slope_at_horizontal=self.outer_slope_at_horizontal, ) if self.outer_radius > self.inner_radius + min(self.thickness_vertical, self.thickness_horizontal): raise ValueError( @@ -72,27 +89,53 @@ def __post_init__(self) -> None: ) if self.corner_direction not in (0, 1, 2, 3): raise ValueError(f"corner_direction must be one of 0, 1, 2, or 3, got {self.corner_direction}") + if any( + slope >= 100 + for slope in [self.inner_slope_at_vertical, self.inner_slope_at_horizontal, self.outer_slope_at_vertical, self.outer_slope_at_horizontal] + ): + raise ValueError("All slopes must be less than 100%") + + @property + def inner_angle_at_vertical(self) -> float: + """Angle of the tangent to the inner radius at the vertical section [radians].""" + return np.deg2rad(slope_to_angle(self.inner_slope_at_vertical)) + + @property + def inner_angle_at_horizontal(self) -> float: + """Angle of the tangent to the inner radius at the horizontal section [radians].""" + return np.deg2rad(slope_to_angle(self.inner_slope_at_horizontal)) + + @property + def outer_angle_at_vertical(self) -> float: + """Angle of the tangent to the outer radius at the vertical section [radians].""" + return np.deg2rad(slope_to_angle(self.outer_slope_at_vertical)) + + @property + def outer_angle_at_horizontal(self) -> float: + """Angle of the tangent to the outer radius at the horizontal section [radians].""" + return np.deg2rad(slope_to_angle(self.outer_slope_at_horizontal)) @property def width_rectangle(self) -> MM: - """Width of the rectangle part of the corner cross-section [mm].""" + """Width of the surrounding rectangle of the corner cross-section [mm].""" return self.thickness_horizontal + self.inner_radius @property def height_rectangle(self) -> MM: - """Height of the rectangle part of the corner cross-section [mm].""" + """Height of the surrounding rectangle of the corner cross-section [mm].""" return self.thickness_vertical + self.inner_radius @property def polygon(self) -> Polygon: """Shapely Polygon representing the corner cross-section.""" - lr = (self.x + self.width_rectangle, self.y) - ul = (self.x, self.y + self.height_rectangle) + lower_corner = (self.x + self.width_rectangle, self.y + np.sin(self.inner_angle_at_horizontal) * self.inner_radius) + upper_corner = (self.x + np.sin(self.inner_angle_at_vertical) * self.inner_radius, self.y + self.height_rectangle) n = 16 # Outer arc (from vertical to horizontal) - theta_outer = np.linspace(0, np.pi / 2, n) + theta_outer = np.linspace(self.outer_angle_at_horizontal, np.pi / 2 - self.outer_angle_at_vertical, n) + outer_arc = np.column_stack( ( self.x + self.width_rectangle - self.outer_radius + self.outer_radius * np.cos(theta_outer), @@ -101,7 +144,7 @@ def polygon(self) -> Polygon: ) # Inner arc (from horizontal to vertical, reversed) - theta_inner = np.linspace(0, np.pi / 2, n) + theta_inner = np.linspace(self.inner_angle_at_horizontal, np.pi / 2 - self.inner_angle_at_vertical, n) inner_arc = np.column_stack( ( self.x + self.inner_radius * np.cos(theta_inner), @@ -110,7 +153,7 @@ def polygon(self) -> Polygon: )[::-1] # Combine points - points = np.vstack([lr, outer_arc, ul, inner_arc]) + points = np.vstack([lower_corner, outer_arc, upper_corner, inner_arc]) # Remove consecutive duplicate points diff = np.diff(points, axis=0) From cd15e1b10a0f1a69053902b36081f22ddc0a0a3a Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 10:13:08 +0100 Subject: [PATCH 03/23] Remove redundant test for outer radius validation in CircularCorneredCrossSection --- .../test_cross_section_cornered.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tests/structural_sections/test_cross_section_cornered.py b/tests/structural_sections/test_cross_section_cornered.py index 7caf2efac..5a4aac5a2 100644 --- a/tests/structural_sections/test_cross_section_cornered.py +++ b/tests/structural_sections/test_cross_section_cornered.py @@ -34,19 +34,6 @@ def test_raise_error_when_negative_values_are_given(self, kwargs: dict) -> None: with pytest.raises(NegativeValueError): CircularCorneredCrossSection(**kwargs) - def test_invalid_outer_radius_greater_than_inner_plus_thickness(self) -> None: - """Test initialization with an outer radius greater than inner radius plus thickness.""" - with pytest.raises( - ValueError, - match="Outer radius 20 must be smaller than or equal to inner radius 5 plus the thickness 10", - ): - CircularCorneredCrossSection( - thickness_vertical=10, - thickness_horizontal=10, - inner_radius=5, - outer_radius=20, - ) - def test_invalid_corner_direction(self) -> None: """Test initialization with an invalid corner direction.""" with pytest.raises(ValueError, match="corner_direction must be one of 0, 1, 2, or 3, got 4"): From 7ef8555a467e03615b413dd335ceb7c5e1ccf0f0 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 10:35:32 +0100 Subject: [PATCH 04/23] Refactor CircularCorneredCrossSection to improve parameter descriptions and enhance geometry calculations --- .../cross_section_cornered.py | 183 ++++++++++++++---- 1 file changed, 142 insertions(+), 41 deletions(-) diff --git a/blueprints/structural_sections/cross_section_cornered.py b/blueprints/structural_sections/cross_section_cornered.py index 011df979c..d002ecc14 100644 --- a/blueprints/structural_sections/cross_section_cornered.py +++ b/blueprints/structural_sections/cross_section_cornered.py @@ -17,8 +17,8 @@ class CircularCorneredCrossSection(CrossSection): """ Class to represent a square cross-section with a quarter circle cutout for geometric calculations, named as a circular cornered section. - .---- outer arc - ∨ + .---- outer arc .---- outer_arc_ext_at_vertical + ∨ v . . .+-----------------------+ . ⁄ | .⁄ |<-- thickness_vertical @@ -32,9 +32,9 @@ class CircularCorneredCrossSection(CrossSection): Parameters ---------- thickness_vertical : MM - Thickness of the vertical section + Thickness of the vertical section if slopes were all 0% thickness_horizontal : MM - Thickness of the horizontal section + Thickness of the horizontal section if slopes were all 0% inner_radius : MM Inner radius of the corner outer_radius : MM @@ -50,9 +50,9 @@ class CircularCorneredCrossSection(CrossSection): outer_slope_at_horizontal : PERCENTAGE Slope of the tangent to the outer radius at the horizontal section (default 0) x : MM - x-coordinate of the center of the inner_radius (default 0) + x-coordinate of intersection of vertical and horizontal sections (default 0) y : MM - y-coordinate of the center of the inner_radius (default 0) + y-coordinate of intersection of vertical and horizontal sections (default 0) name : str Name of the cross-section (default "Corner") """ @@ -82,11 +82,7 @@ def __post_init__(self) -> None: outer_slope_at_vertical=self.outer_slope_at_vertical, outer_slope_at_horizontal=self.outer_slope_at_horizontal, ) - if self.outer_radius > self.inner_radius + min(self.thickness_vertical, self.thickness_horizontal): - raise ValueError( - f"Outer radius {self.outer_radius} must be smaller than or equal to inner radius {self.inner_radius} " - f"plus the thickness {min(self.thickness_vertical, self.thickness_horizontal)}" - ) + if self.corner_direction not in (0, 1, 2, 3): raise ValueError(f"corner_direction must be one of 0, 1, 2, or 3, got {self.corner_direction}") if any( @@ -115,22 +111,9 @@ def outer_angle_at_horizontal(self) -> float: """Angle of the tangent to the outer radius at the horizontal section [radians].""" return np.deg2rad(slope_to_angle(self.outer_slope_at_horizontal)) - @property - def width_rectangle(self) -> MM: - """Width of the surrounding rectangle of the corner cross-section [mm].""" - return self.thickness_horizontal + self.inner_radius - - @property - def height_rectangle(self) -> MM: - """Height of the surrounding rectangle of the corner cross-section [mm].""" - return self.thickness_vertical + self.inner_radius - @property def polygon(self) -> Polygon: """Shapely Polygon representing the corner cross-section.""" - lower_corner = (self.x + self.width_rectangle, self.y + np.sin(self.inner_angle_at_horizontal) * self.inner_radius) - upper_corner = (self.x + np.sin(self.inner_angle_at_vertical) * self.inner_radius, self.y + self.height_rectangle) - n = 16 # Outer arc (from vertical to horizontal) @@ -138,45 +121,126 @@ def polygon(self) -> Polygon: outer_arc = np.column_stack( ( - self.x + self.width_rectangle - self.outer_radius + self.outer_radius * np.cos(theta_outer), - self.y + self.height_rectangle - self.outer_radius + self.outer_radius * np.sin(theta_outer), + self.outer_radius * np.cos(theta_outer), + self.outer_radius * np.sin(theta_outer), ) ) + o_a_width = np.max(outer_arc[:, 0]) - np.min(outer_arc[:, 0]) + o_a_height = np.max(outer_arc[:, 1]) - np.min(outer_arc[:, 1]) # Inner arc (from horizontal to vertical, reversed) theta_inner = np.linspace(self.inner_angle_at_horizontal, np.pi / 2 - self.inner_angle_at_vertical, n) inner_arc = np.column_stack( ( - self.x + self.inner_radius * np.cos(theta_inner), - self.y + self.inner_radius * np.sin(theta_inner), + self.inner_radius * np.cos(theta_inner), + self.inner_radius * np.sin(theta_inner), ) )[::-1] + i_a_width = np.max(inner_arc[:, 0]) - np.min(inner_arc[:, 0]) + i_a_height = np.max(inner_arc[:, 1]) - np.min(inner_arc[:, 1]) + + # Based on input its possible that either the outer arc or the inner arc is wider/taller + # than the other (plus thickness). To align them, we need to extend one of the arcs. + # Solve for extensions to make inner and outer arcs align + # Full system of equations: i_a_width + thickness_horizontal + i_a_ext_h*sin(i_angle_h) + # + i_a_ext_v*cos(i_angle_v) = o_a_width + o_a_ext_h*sin(o_angle_h) + o_a_ext_v*cos(o_angle_v) + # and i_a_height + thickness_vertical + i_a_ext_h*cos(i_angle_h) + i_a_ext_v*sin(i_angle_v) + # = o_a_height + o_a_ext_h*cos(o_angle_h) + o_a_ext_v*sin(o_angle_v) + # + # Four unknowns: i_a_ext_h, i_a_ext_v, o_a_ext_h, o_a_ext_v + # Two of them are zero, two of them are positive + # Currently there is no one solid way to determine which two are zero, so we try all four combinations + + # Try strategies: (outer_h_zero, outer_v_zero, inner_h_zero, inner_v_zero) + for o_h_z, o_v_z, i_h_z, i_v_z in [(1, 1, 0, 0), (0, 0, 1, 1), (1, 0, 0, 1), (0, 1, 1, 0)]: + if o_h_z and o_v_z: + a = np.array( + [ + [np.sin(self.inner_angle_at_horizontal), np.cos(self.inner_angle_at_vertical)], + [np.cos(self.inner_angle_at_horizontal), np.sin(self.inner_angle_at_vertical)], + ] + ) + b = np.array([o_a_width - i_a_width - self.thickness_horizontal, o_a_height - i_a_height - self.thickness_vertical]) + i_a_ext_at_horizontal, i_a_ext_at_vertical = np.linalg.solve(a, b) + o_a_ext_at_horizontal = o_a_ext_at_vertical = 0 + elif i_h_z and i_v_z: + a = np.array( + [ + [np.sin(self.outer_angle_at_horizontal), np.cos(self.outer_angle_at_vertical)], + [np.cos(self.outer_angle_at_horizontal), np.sin(self.outer_angle_at_vertical)], + ] + ) + b = np.array([i_a_width + self.thickness_horizontal - o_a_width, i_a_height + self.thickness_vertical - o_a_height]) + o_a_ext_at_horizontal, o_a_ext_at_vertical = np.linalg.solve(a, b) + i_a_ext_at_horizontal = i_a_ext_at_vertical = 0 + elif o_h_z and i_v_z: + a = np.array( + [ + [np.sin(self.inner_angle_at_horizontal), np.cos(self.outer_angle_at_vertical)], + [np.cos(self.inner_angle_at_horizontal), np.sin(self.outer_angle_at_vertical)], + ] + ) + b = np.array([o_a_width - i_a_width - self.thickness_horizontal, o_a_height - i_a_height - self.thickness_vertical]) + i_a_ext_at_horizontal, o_a_ext_at_vertical = np.linalg.solve(a, b) + o_a_ext_at_horizontal = i_a_ext_at_vertical = 0 + else: + a = np.array( + [ + [np.sin(self.outer_angle_at_horizontal), np.cos(self.inner_angle_at_vertical)], + [np.cos(self.outer_angle_at_horizontal), np.sin(self.inner_angle_at_vertical)], + ] + ) + b = np.array([o_a_width - i_a_width - self.thickness_horizontal, o_a_height - i_a_height - self.thickness_vertical]) + o_a_ext_at_horizontal, i_a_ext_at_vertical = np.linalg.solve(a, b) + i_a_ext_at_horizontal = o_a_ext_at_vertical = 0 + + if all(x >= 0 for x in [i_a_ext_at_horizontal, i_a_ext_at_vertical, o_a_ext_at_horizontal, o_a_ext_at_vertical]): + break + + total_width = ( + o_a_width + o_a_ext_at_horizontal * np.sin(self.outer_angle_at_horizontal) + o_a_ext_at_vertical * np.cos(self.outer_angle_at_vertical) + ) + total_height = ( + o_a_height + o_a_ext_at_horizontal * np.cos(self.outer_angle_at_horizontal) + o_a_ext_at_vertical * np.sin(self.outer_angle_at_vertical) + ) + + # Translate outer arc points + outer_arc[:, 0] += o_a_ext_at_vertical * np.cos(self.outer_angle_at_vertical) - np.min(outer_arc[:, 0]) + outer_arc[:, 1] += o_a_ext_at_horizontal * np.cos(self.outer_angle_at_horizontal) - np.min(outer_arc[:, 1]) + + # Translate inner arc points + inner_arc[:, 0] += i_a_ext_at_vertical * np.cos(self.inner_angle_at_vertical) - np.min(inner_arc[:, 0]) + inner_arc[:, 1] += i_a_ext_at_horizontal * np.cos(self.inner_angle_at_horizontal) - np.min(inner_arc[:, 1]) # Combine points - points = np.vstack([lower_corner, outer_arc, upper_corner, inner_arc]) + points = np.vstack( + [ + (total_width, 0), + outer_arc, + (0, total_height), + (0, total_height - self.thickness_vertical), + inner_arc, + (total_width - self.thickness_horizontal, 0), + (total_width, 0), + ] + ) # Remove consecutive duplicate points - diff = np.diff(points, axis=0) - mask = np.any(diff != 0, axis=1) - mask = np.insert(mask, 0, True) - points = points[mask] + mask = np.any(np.diff(points, axis=0) != 0, axis=1) + points = points[np.insert(mask, 0, True)] # Create transformation matrices for flipping flip_x = np.array([[-1, 0], [0, 1]]) flip_y = np.array([[1, 0], [0, -1]]) - # Center points around (self.x, self.y) - points_centered = points - np.array([self.x, self.y]) - # Apply flips based on corner_direction if self.corner_direction in (1, 2): - points_centered = points_centered @ flip_x + points = points @ flip_x if self.corner_direction in (2, 3): - points_centered = points_centered @ flip_y - - # Shift points back - points = points_centered + np.array([self.x, self.y]) + points = points @ flip_y + # Shift points + points += np.array([self.x, self.y]) points = np.array([tuple(pt) for pt in points]) return Polygon(np.round(points, self.ACCURACY)) @@ -199,3 +263,40 @@ def geometry(self, mesh_size: MM | None = None) -> Geometry: geom = Geometry(geom=self.polygon) geom.create_mesh(mesh_sizes=mesh_size) return geom + + +if __name__ == "__main__": + """Example usage with plot.""" + import matplotlib.pyplot as plt + + # Create a circular cornered cross-section + section = CircularCorneredCrossSection( + thickness_vertical=50, + thickness_horizontal=100, + inner_radius=50, + outer_radius=0, + inner_slope_at_vertical=20, + inner_slope_at_horizontal=20, + outer_slope_at_vertical=20, + outer_slope_at_horizontal=20, + x=10, + y=10, + corner_direction=3, + ) + + # Get the geometry + geom = section.geometry() + + # Plot the cross-section + fig, ax = plt.subplots(figsize=(8, 8)) + x, y = section.polygon.exterior.xy + ax.plot(x, y, "b-", linewidth=2, label="Cross-section outline") + ax.fill(x, y, alpha=0.3) + ax.set_xlabel("x [mm]") + ax.set_ylabel("y [mm]") + ax.set_title("Circular Cornered Cross-Section") + ax.axis("equal") + ax.grid(True, alpha=0.3) + ax.legend() + plt.tight_layout() + plt.show() From c2dfa29d3784cfa17cd407067dc57e2b9f5cc022 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 11:56:21 +0100 Subject: [PATCH 05/23] Refactor test for negative value validation in CircularCorneredCrossSection to include additional slope parameters --- .../test_cross_section_cornered.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/structural_sections/test_cross_section_cornered.py b/tests/structural_sections/test_cross_section_cornered.py index 5a4aac5a2..1180bec61 100644 --- a/tests/structural_sections/test_cross_section_cornered.py +++ b/tests/structural_sections/test_cross_section_cornered.py @@ -23,16 +23,26 @@ def test_geometry(self, qcs_cross_section: CircularCorneredCrossSection) -> None @pytest.mark.parametrize( "kwargs", [ - {"thickness_vertical": -1, "thickness_horizontal": 10, "inner_radius": 5, "outer_radius": 10}, - {"thickness_vertical": 10, "thickness_horizontal": -1, "inner_radius": 5, "outer_radius": 10}, - {"thickness_vertical": 10, "thickness_horizontal": 10, "inner_radius": -1, "outer_radius": 10}, - {"thickness_vertical": 10, "thickness_horizontal": 10, "inner_radius": 5, "outer_radius": -1}, + {"thickness_vertical": -1}, + {"thickness_horizontal": -1}, + {"inner_radius": -1}, + {"outer_radius": -1}, + {"inner_slope_at_vertical": -1}, + {"inner_slope_at_horizontal": -1}, + {"outer_slope_at_vertical": -1}, + {"outer_slope_at_horizontal": -1}, ], ) def test_raise_error_when_negative_values_are_given(self, kwargs: dict) -> None: """Test NegativeValueError is raised for negative values.""" + defaults = { + "thickness_vertical": 10, + "thickness_horizontal": 10, + "inner_radius": 5, + "outer_radius": 10, + } with pytest.raises(NegativeValueError): - CircularCorneredCrossSection(**kwargs) + CircularCorneredCrossSection(**{**defaults, **kwargs}) def test_invalid_corner_direction(self) -> None: """Test initialization with an invalid corner direction.""" From 20b645b30ea2a678636badc0f686ec9a9da3a3b5 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 11:56:39 +0100 Subject: [PATCH 06/23] Enhance CircularCorneredCrossSection: add total width and height properties, improve parameter descriptions, and update example usage --- .../cross_section_cornered.py | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/blueprints/structural_sections/cross_section_cornered.py b/blueprints/structural_sections/cross_section_cornered.py index d002ecc14..b83597cb7 100644 --- a/blueprints/structural_sections/cross_section_cornered.py +++ b/blueprints/structural_sections/cross_section_cornered.py @@ -8,7 +8,7 @@ from blueprints.math_helpers import slope_to_angle from blueprints.structural_sections._cross_section import CrossSection -from blueprints.type_alias import MM, PERCENTAGE +from blueprints.type_alias import MM, PERCENTAGE, RAD from blueprints.validations import raise_if_negative @@ -17,9 +17,9 @@ class CircularCorneredCrossSection(CrossSection): """ Class to represent a square cross-section with a quarter circle cutout for geometric calculations, named as a circular cornered section. - .---- outer arc .---- outer_arc_ext_at_vertical + .---- outer arc .---- o_a_ext_at_vertical ∨ v - . . .+-----------------------+ + . .+-----------------------+ . ⁄ | .⁄ |<-- thickness_vertical + | @@ -27,14 +27,16 @@ class CircularCorneredCrossSection(CrossSection): | / | / | | - +-------------------+<-- thickness_horizontal + +-------------------+ x-- coordinate reference point + ^ + .---- thickness_horizontal Parameters ---------- thickness_vertical : MM - Thickness of the vertical section if slopes were all 0% + Thickness of the vertical section thickness_horizontal : MM - Thickness of the horizontal section if slopes were all 0% + Thickness of the horizontal section inner_radius : MM Inner radius of the corner outer_radius : MM @@ -50,9 +52,9 @@ class CircularCorneredCrossSection(CrossSection): outer_slope_at_horizontal : PERCENTAGE Slope of the tangent to the outer radius at the horizontal section (default 0) x : MM - x-coordinate of intersection of vertical and horizontal sections (default 0) + x-coordinate of reference point y : MM - y-coordinate of intersection of vertical and horizontal sections (default 0) + y-coordinate of reference point name : str Name of the cross-section (default "Corner") """ @@ -61,13 +63,13 @@ class CircularCorneredCrossSection(CrossSection): thickness_horizontal: MM inner_radius: MM outer_radius: MM + corner_direction: int = 0 # 0 = ↰, 1 = ↱, 2 = ↳, 3 = ↲ inner_slope_at_vertical: PERCENTAGE = 0 inner_slope_at_horizontal: PERCENTAGE = 0 outer_slope_at_vertical: PERCENTAGE = 0 outer_slope_at_horizontal: PERCENTAGE = 0 x: MM = 0 y: MM = 0 - corner_direction: int = 0 # 0 = ↰, 1 = ↱, 2 = ↳, 3 = ↲ name: str = "Corner" def __post_init__(self) -> None: @@ -92,25 +94,35 @@ def __post_init__(self) -> None: raise ValueError("All slopes must be less than 100%") @property - def inner_angle_at_vertical(self) -> float: + def inner_angle_at_vertical(self) -> RAD: """Angle of the tangent to the inner radius at the vertical section [radians].""" return np.deg2rad(slope_to_angle(self.inner_slope_at_vertical)) @property - def inner_angle_at_horizontal(self) -> float: + def inner_angle_at_horizontal(self) -> RAD: """Angle of the tangent to the inner radius at the horizontal section [radians].""" return np.deg2rad(slope_to_angle(self.inner_slope_at_horizontal)) @property - def outer_angle_at_vertical(self) -> float: + def outer_angle_at_vertical(self) -> RAD: """Angle of the tangent to the outer radius at the vertical section [radians].""" return np.deg2rad(slope_to_angle(self.outer_slope_at_vertical)) @property - def outer_angle_at_horizontal(self) -> float: + def outer_angle_at_horizontal(self) -> RAD: """Angle of the tangent to the outer radius at the horizontal section [radians].""" return np.deg2rad(slope_to_angle(self.outer_slope_at_horizontal)) + @property + def total_width(self) -> MM: + """Total width of the cornered section [mm].""" + return max(self.polygon.exterior.xy[0]) - min(self.polygon.exterior.xy[0]) + + @property + def total_height(self) -> MM: + """Total height of the cornered section [mm].""" + return max(self.polygon.exterior.xy[1]) - min(self.polygon.exterior.xy[1]) + @property def polygon(self) -> Polygon: """Shapely Polygon representing the corner cross-section.""" @@ -271,17 +283,17 @@ def geometry(self, mesh_size: MM | None = None) -> Geometry: # Create a circular cornered cross-section section = CircularCorneredCrossSection( - thickness_vertical=50, - thickness_horizontal=100, - inner_radius=50, - outer_radius=0, - inner_slope_at_vertical=20, - inner_slope_at_horizontal=20, - outer_slope_at_vertical=20, - outer_slope_at_horizontal=20, - x=10, - y=10, - corner_direction=3, + thickness_vertical=15, + thickness_horizontal=40, + inner_radius=0, + outer_radius=6, + inner_slope_at_vertical=0, + inner_slope_at_horizontal=0, + outer_slope_at_vertical=8, + outer_slope_at_horizontal=0, + x=0, + y=0, + corner_direction=0, ) # Get the geometry From fab32e61b6078e9864153b0f6d3d1244eb2ff387 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 14:02:52 +0100 Subject: [PATCH 07/23] Add reference point parameter to CircularCorneredCrossSection and update geometry calculations --- .../cross_section_cornered.py | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/blueprints/structural_sections/cross_section_cornered.py b/blueprints/structural_sections/cross_section_cornered.py index b83597cb7..45c96e5f3 100644 --- a/blueprints/structural_sections/cross_section_cornered.py +++ b/blueprints/structural_sections/cross_section_cornered.py @@ -17,9 +17,11 @@ class CircularCorneredCrossSection(CrossSection): """ Class to represent a square cross-section with a quarter circle cutout for geometric calculations, named as a circular cornered section. - .---- outer arc .---- o_a_ext_at_vertical - ∨ v - . .+-----------------------+ + .---- outer reference point + | + | .---- outer arc .---- o_a_ext_at_vertical + v ∨ v + x . .+-----------------------+ . ⁄ | .⁄ |<-- thickness_vertical + | @@ -27,7 +29,7 @@ class CircularCorneredCrossSection(CrossSection): | / | / | | - +-------------------+ x-- coordinate reference point + +-------------------+ x-- intersection reference point ^ .---- thickness_horizontal @@ -55,6 +57,11 @@ class CircularCorneredCrossSection(CrossSection): x-coordinate of reference point y : MM y-coordinate of reference point + reference_point : str + Where x and y are located, options are + 'intersection' (intersection of vertical and horizontal sections), + 'outer' (where corner outer arc would be if it was sharp 90 degree corner) + (default 'intersection') name : str Name of the cross-section (default "Corner") """ @@ -68,6 +75,7 @@ class CircularCorneredCrossSection(CrossSection): inner_slope_at_horizontal: PERCENTAGE = 0 outer_slope_at_vertical: PERCENTAGE = 0 outer_slope_at_horizontal: PERCENTAGE = 0 + reference_point: str = "intersection" x: MM = 0 y: MM = 0 name: str = "Corner" @@ -85,6 +93,8 @@ def __post_init__(self) -> None: outer_slope_at_horizontal=self.outer_slope_at_horizontal, ) + if self.reference_point not in ("intersection", "outer"): + raise ValueError(f"reference_point must be either 'intersection' or 'outer', got {self.reference_point}") if self.corner_direction not in (0, 1, 2, 3): raise ValueError(f"corner_direction must be one of 0, 1, 2, or 3, got {self.corner_direction}") if any( @@ -241,15 +251,16 @@ def polygon(self) -> Polygon: mask = np.any(np.diff(points, axis=0) != 0, axis=1) points = points[np.insert(mask, 0, True)] - # Create transformation matrices for flipping - flip_x = np.array([[-1, 0], [0, 1]]) - flip_y = np.array([[1, 0], [0, -1]]) + if self.reference_point == "outer": + # Shift points to make outer reference point at (x, y) + points[:, 0] -= total_width + points[:, 1] -= total_height # Apply flips based on corner_direction if self.corner_direction in (1, 2): - points = points @ flip_x + points = points @ np.array([[-1, 0], [0, 1]]) if self.corner_direction in (2, 3): - points = points @ flip_y + points = points @ np.array([[1, 0], [0, -1]]) # Shift points points += np.array([self.x, self.y]) From 50951b720f2811cfbb099eb44865e5dc8babdb80 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 14:04:39 +0100 Subject: [PATCH 08/23] Add UNP-Profile steel section implementation with customizable attributes and plotting functionality --- .../steel/steel_cross_sections/unp_profile.py | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py diff --git a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py new file mode 100644 index 000000000..3f99a086e --- /dev/null +++ b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py @@ -0,0 +1,277 @@ +"""UNP-Profile steel section.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Self + +import numpy as np +from matplotlib import pyplot as plt + +from blueprints.materials.steel import SteelMaterial +from blueprints.math_helpers import slope_to_angle +from blueprints.structural_sections.cross_section_cornered import CircularCorneredCrossSection +from blueprints.structural_sections.cross_section_rectangle import RectangularCrossSection +from blueprints.structural_sections.steel.steel_cross_sections._steel_cross_section import CombinedSteelCrossSection +from blueprints.structural_sections.steel.steel_cross_sections.plotters.general_steel_plotter import plot_shapes +from blueprints.structural_sections.steel.steel_cross_sections.standard_profiles.unp import UNP +from blueprints.structural_sections.steel.steel_element import SteelElement +from blueprints.type_alias import MM, PERCENTAGE + + +@dataclass(kw_only=True) +class UNPSteelProfile(CombinedSteelCrossSection): + """Representation of a UNP-Profile steel section. + This can be used to create a custom UNP-profile or to create a UNP-profile from a standard profile. + + For standard profiles, use the `from_standard_profile` class method. + For example, + ```python + unp_profile = UNPSteelProfile.from_standard_profile(profile=UNP.UNP200, steel_material=SteelMaterial(SteelStrengthClass.S355)) + ``` + + Attributes + ---------- + steel_material : SteelMaterial + Steel material properties for the profile. + top_flange_total_width : MM + The total width of the top flange [mm]. + top_flange_thickness : MM + The thickness of the top flange [mm]. + bottom_flange_total_width : MM + The total width of the bottom flange [mm]. + bottom_flange_thickness : MM + The thickness of the bottom flange [mm]. + total_height : MM + The total height of the profile [mm]. + web_thickness : MM + The thickness of the web [mm]. + top_root_fillet_radius : MM | None + The radius of the curved corners of the top flange. Default is None, the corner radius is then taken as the thickness of top flange. + top_toe_radius : MM + The radius of the outer corners of the top flange. Default is 0, meaning sharp corner. + bottom_root_fillet_radius : MM | None + The radius of the curved corners of the bottom flange. Default is None, the corner radius is then taken as the thickness of bottom flange. + bottom_toe_radius : MM + The radius of the outer corners of the bottom flange. Default is 0, meaning sharp corner. + top_slope : PERCENTAGE + The slope of the top flange. Default is 0. + bottom_slope : PERCENTAGE + The slope of the bottom flange. Default is 0. + name : str + The name of the profile. Default is "UNP-Profile". If corrosion is applied, the name will include the corrosion value. + """ + + steel_material: SteelMaterial + top_flange_total_width: MM + top_flange_thickness: MM + bottom_flange_total_width: MM + bottom_flange_thickness: MM + total_height: MM + web_thickness: MM + top_root_fillet_radius: MM | None = None + top_toe_radius: MM = 0 + bottom_root_fillet_radius: MM | None = None + bottom_toe_radius: MM = 0 + top_slope: PERCENTAGE = 0.0 + bottom_slope: PERCENTAGE = 0.0 + name: str = "UNP-Profile" + + def __post_init__(self) -> None: + """Initialize the UNP-profile steel section.""" + self.top_root_fillet_radius = self.top_root_fillet_radius if self.top_root_fillet_radius is not None else self.top_flange_thickness + self.bottom_root_fillet_radius = ( + self.bottom_root_fillet_radius if self.bottom_root_fillet_radius is not None else self.bottom_flange_thickness + ) + + # Create curves for the corners of the flanges + # It is assumed that the thickness is measured vertically halfway the total width of the element + # The results of this allign with standard UNP profiles databases + top_angle = np.deg2rad(slope_to_angle(self.top_slope)) + bottom_angle = np.deg2rad(slope_to_angle(self.bottom_slope)) + top_thickness_at_web = ( + self.top_flange_thickness + + (self.top_flange_total_width / 2 - self.web_thickness - self.top_root_fillet_radius * np.cos(top_angle)) * self.top_slope / 100 + ) + bottom_thickness_at_web = ( + self.bottom_flange_thickness + + (self.bottom_flange_total_width / 2 - self.web_thickness - self.bottom_root_fillet_radius * np.cos(bottom_angle)) + * self.bottom_slope + / 100 + ) + + self.corner_top = CircularCorneredCrossSection( + name="Corner top", + inner_radius=self.top_root_fillet_radius, + outer_radius=0, + x=0, + y=self.total_height / 2, + corner_direction=1, + thickness_horizontal=self.web_thickness, + thickness_vertical=top_thickness_at_web, + inner_slope_at_vertical=self.top_slope, + reference_point="outer", + ) + self.corner_bottom = CircularCorneredCrossSection( + name="Corner bottom", + inner_radius=self.bottom_root_fillet_radius, + outer_radius=0, + x=0, + y=-self.total_height / 2, + corner_direction=2, + thickness_horizontal=self.web_thickness, + thickness_vertical=bottom_thickness_at_web, + inner_slope_at_vertical=self.bottom_slope, + reference_point="outer", + ) + + self.web = RectangularCrossSection( + name="Web", + width=self.web_thickness, + height=self.total_height - self.corner_bottom.total_height - self.corner_top.total_height, + x=self.web_thickness / 2, + y=0, + ) + + self.top_flange = CircularCorneredCrossSection( + name="Top flange", + inner_radius=0, + outer_radius=self.top_toe_radius, + x=self.corner_top.total_width, + y=self.total_height / 2, + corner_direction=3, + thickness_horizontal=self.top_flange_total_width - self.corner_top.total_width, + thickness_vertical=top_thickness_at_web, + outer_slope_at_vertical=self.top_slope, + ) + + self.bottom_flange = CircularCorneredCrossSection( + name="Bottom flange", + inner_radius=0, + outer_radius=self.bottom_toe_radius, + x=self.corner_bottom.total_width, + y=-self.total_height / 2, + corner_direction=0, + thickness_horizontal=self.bottom_flange_total_width - self.corner_bottom.total_width, + thickness_vertical=bottom_thickness_at_web, + outer_slope_at_vertical=self.bottom_slope, + ) + + # Create the steel elements + self.elements = [ + SteelElement( + cross_section=self.corner_top, + material=self.steel_material, + nominal_thickness=self.bottom_flange_thickness, + ), + SteelElement( + cross_section=self.corner_bottom, + material=self.steel_material, + nominal_thickness=self.bottom_flange_thickness, + ), + SteelElement( + cross_section=self.web, + material=self.steel_material, + nominal_thickness=self.web_thickness, + ), + SteelElement( + cross_section=self.top_flange, + material=self.steel_material, + nominal_thickness=self.top_flange_thickness, + ), + SteelElement( + cross_section=self.bottom_flange, + material=self.steel_material, + nominal_thickness=self.bottom_flange_thickness, + ), + ] + + @classmethod + def from_standard_profile( + cls, + profile: UNP, + steel_material: SteelMaterial, + corrosion: MM = 0, + ) -> Self: + """Create a UNP-profile from a set of standard profiles already defined in Blueprints. + + Blueprints offers standard profiles for UNP. This method allows you to create a UNP-profile. + + Parameters + ---------- + profile : UNP + Any of the standard UNP profiles defined in Blueprints. + steel_material : SteelMaterial + Steel material properties for the profile. + corrosion : MM, optional + Corrosion thickness per side (default is 0). + """ + top_flange_total_width = profile.top_flange_total_width - corrosion * 2 + top_flange_thickness = profile.top_flange_thickness - corrosion * 2 + bottom_flange_total_width = profile.bottom_flange_total_width - corrosion * 2 + bottom_flange_thickness = profile.bottom_flange_thickness - corrosion * 2 + total_height = profile.total_height - corrosion * 2 + web_thickness = profile.web_thickness - corrosion * 2 + + if any( + [ + top_flange_thickness < 1e-3, + bottom_flange_thickness < 1e-3, + web_thickness < 1e-3, + ] + ): + raise ValueError("The profile has fully corroded.") + + name = profile.alias + if corrosion: + name += f" (corrosion: {corrosion} mm)" + + return cls( + top_flange_total_width=top_flange_total_width, + top_flange_thickness=top_flange_thickness, + bottom_flange_total_width=bottom_flange_total_width, + bottom_flange_thickness=bottom_flange_thickness, + total_height=total_height, + web_thickness=web_thickness, + steel_material=steel_material, + top_root_fillet_radius=profile.root_fillet_radius, + top_toe_radius=profile.toe_radius, + bottom_root_fillet_radius=profile.root_fillet_radius, + bottom_toe_radius=profile.toe_radius, + top_slope=profile.slope, + bottom_slope=profile.slope, + name=name, + ) + + def plot(self, plotter: Callable[[CombinedSteelCrossSection], plt.Figure] | None = None, *args, **kwargs) -> plt.Figure: + """Plot the cross-section. Making use of the standard plotter. + + Parameters + ---------- + plotter : Callable[CombinedSteelCrossSection, plt.Figure] | None + The plotter function to use. If None, the default Blueprints plotter for steel sections is used. + *args + Additional arguments passed to the plotter. + **kwargs + Additional keyword arguments passed to the plotter. + """ + if plotter is None: + plotter = plot_shapes + return plotter( + self, + *args, + **kwargs, + ) + + +if __name__ == "__main__": + # Create a UNP200 profile with S355 steel material + from blueprints.codes.eurocode.en_1993_1_1_2005.chapter_3_materials.table_3_1 import SteelStrengthClass + + unp_profile = UNPSteelProfile.from_standard_profile( + profile=UNP.UNP200, + steel_material=SteelMaterial(SteelStrengthClass.S355), + ) + + props = unp_profile.section_properties() + fig = unp_profile.plot() + plt.show() From 590830ad071f6b5eaa5882bb6738d40d06f03ae1 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 15:35:09 +0100 Subject: [PATCH 09/23] Refactor UNPSteelProfile: improve flange thickness calculations and update toe radius handling for better geometry accuracy --- .../steel/steel_cross_sections/unp_profile.py | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py index 3f99a086e..439380f5e 100644 --- a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py +++ b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py @@ -84,10 +84,11 @@ def __post_init__(self) -> None: ) # Create curves for the corners of the flanges - # It is assumed that the thickness is measured vertically halfway the total width of the element + # It is used that the thickness is measured vertically halfway the total width of the flange # The results of this allign with standard UNP profiles databases top_angle = np.deg2rad(slope_to_angle(self.top_slope)) bottom_angle = np.deg2rad(slope_to_angle(self.bottom_slope)) + top_thickness_at_web = ( self.top_flange_thickness + (self.top_flange_total_width / 2 - self.web_thickness - self.top_root_fillet_radius * np.cos(top_angle)) * self.top_slope / 100 @@ -99,6 +100,9 @@ def __post_init__(self) -> None: / 100 ) + top_thickness_at_toe = max(0, self.top_flange_thickness - (self.top_flange_total_width / 2) * self.top_slope / 100) + bottom_thickness_at_toe = max(0, self.bottom_flange_thickness - (self.bottom_flange_total_width / 2) * self.bottom_slope / 100) + self.corner_top = CircularCorneredCrossSection( name="Corner top", inner_radius=self.top_root_fillet_radius, @@ -132,10 +136,14 @@ def __post_init__(self) -> None: y=0, ) + # using modelled toe radius to avoid impossible geometry with small mesh + modelled_top_toe_radius = ( + min(self.top_toe_radius, 0.95 * top_thickness_at_toe) if min(self.top_toe_radius, 0.95 * top_thickness_at_toe) >= 1.0 else 0 + ) self.top_flange = CircularCorneredCrossSection( name="Top flange", inner_radius=0, - outer_radius=self.top_toe_radius, + outer_radius=modelled_top_toe_radius, x=self.corner_top.total_width, y=self.total_height / 2, corner_direction=3, @@ -144,10 +152,14 @@ def __post_init__(self) -> None: outer_slope_at_vertical=self.top_slope, ) + # using modelled toe radius to avoid impossible geometry with small mesh + modelled_bottom_toe_radius = ( + min(self.bottom_toe_radius, 0.95 * bottom_thickness_at_toe) if min(self.bottom_toe_radius, 0.95 * bottom_thickness_at_toe) >= 1.0 else 0 + ) self.bottom_flange = CircularCorneredCrossSection( name="Bottom flange", inner_radius=0, - outer_radius=self.bottom_toe_radius, + outer_radius=modelled_bottom_toe_radius, x=self.corner_bottom.total_width, y=-self.total_height / 2, corner_direction=0, @@ -211,6 +223,10 @@ def from_standard_profile( bottom_flange_thickness = profile.bottom_flange_thickness - corrosion * 2 total_height = profile.total_height - corrosion * 2 web_thickness = profile.web_thickness - corrosion * 2 + top_root_fillet_radius = profile.root_fillet_radius + corrosion + bottom_root_fillet_radius = profile.root_fillet_radius + corrosion + top_toe_radius = max(profile.toe_radius - corrosion, 0) + bottom_toe_radius = max(profile.toe_radius - corrosion, 0) if any( [ @@ -233,10 +249,10 @@ def from_standard_profile( total_height=total_height, web_thickness=web_thickness, steel_material=steel_material, - top_root_fillet_radius=profile.root_fillet_radius, - top_toe_radius=profile.toe_radius, - bottom_root_fillet_radius=profile.root_fillet_radius, - bottom_toe_radius=profile.toe_radius, + top_root_fillet_radius=top_root_fillet_radius, + top_toe_radius=top_toe_radius, + bottom_root_fillet_radius=bottom_root_fillet_radius, + bottom_toe_radius=bottom_toe_radius, top_slope=profile.slope, bottom_slope=profile.slope, name=name, @@ -261,17 +277,3 @@ def plot(self, plotter: Callable[[CombinedSteelCrossSection], plt.Figure] | None *args, **kwargs, ) - - -if __name__ == "__main__": - # Create a UNP200 profile with S355 steel material - from blueprints.codes.eurocode.en_1993_1_1_2005.chapter_3_materials.table_3_1 import SteelStrengthClass - - unp_profile = UNPSteelProfile.from_standard_profile( - profile=UNP.UNP200, - steel_material=SteelMaterial(SteelStrengthClass.S355), - ) - - props = unp_profile.section_properties() - fig = unp_profile.plot() - plt.show() From ca56c16bd29d7d4a00f07bdc1a3cfc9261f7f206 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 15:38:20 +0100 Subject: [PATCH 10/23] Add validation for arc extensions in CircularCorneredCrossSection and update example usage for top flange cross-section --- .../cross_section_cornered.py | 53 +++---------------- 1 file changed, 8 insertions(+), 45 deletions(-) diff --git a/blueprints/structural_sections/cross_section_cornered.py b/blueprints/structural_sections/cross_section_cornered.py index 45c96e5f3..cdf28916b 100644 --- a/blueprints/structural_sections/cross_section_cornered.py +++ b/blueprints/structural_sections/cross_section_cornered.py @@ -139,23 +139,20 @@ def polygon(self) -> Polygon: n = 16 # Outer arc (from vertical to horizontal) - theta_outer = np.linspace(self.outer_angle_at_horizontal, np.pi / 2 - self.outer_angle_at_vertical, n) - outer_arc = np.column_stack( ( - self.outer_radius * np.cos(theta_outer), - self.outer_radius * np.sin(theta_outer), + self.outer_radius * np.cos(np.linspace(self.outer_angle_at_horizontal, np.pi / 2 - self.outer_angle_at_vertical, n)), + self.outer_radius * np.sin(np.linspace(self.outer_angle_at_horizontal, np.pi / 2 - self.outer_angle_at_vertical, n)), ) ) o_a_width = np.max(outer_arc[:, 0]) - np.min(outer_arc[:, 0]) o_a_height = np.max(outer_arc[:, 1]) - np.min(outer_arc[:, 1]) # Inner arc (from horizontal to vertical, reversed) - theta_inner = np.linspace(self.inner_angle_at_horizontal, np.pi / 2 - self.inner_angle_at_vertical, n) inner_arc = np.column_stack( ( - self.inner_radius * np.cos(theta_inner), - self.inner_radius * np.sin(theta_inner), + self.inner_radius * np.cos(np.linspace(self.inner_angle_at_horizontal, np.pi / 2 - self.inner_angle_at_vertical, n)), + self.inner_radius * np.sin(np.linspace(self.inner_angle_at_horizontal, np.pi / 2 - self.inner_angle_at_vertical, n)), ) )[::-1] i_a_width = np.max(inner_arc[:, 0]) - np.min(inner_arc[:, 0]) @@ -219,6 +216,9 @@ def polygon(self) -> Polygon: if all(x >= 0 for x in [i_a_ext_at_horizontal, i_a_ext_at_vertical, o_a_ext_at_horizontal, o_a_ext_at_vertical]): break + if not all(x >= 0 for x in [i_a_ext_at_horizontal, i_a_ext_at_vertical, o_a_ext_at_horizontal, o_a_ext_at_vertical]): + raise ValueError("Could not determine valid extensions to align inner and outer arcs.") + total_width = ( o_a_width + o_a_ext_at_horizontal * np.sin(self.outer_angle_at_horizontal) + o_a_ext_at_vertical * np.cos(self.outer_angle_at_vertical) ) @@ -251,8 +251,8 @@ def polygon(self) -> Polygon: mask = np.any(np.diff(points, axis=0) != 0, axis=1) points = points[np.insert(mask, 0, True)] + # Shift points to make outer reference point at (x, y) if self.reference_point == "outer": - # Shift points to make outer reference point at (x, y) points[:, 0] -= total_width points[:, 1] -= total_height @@ -286,40 +286,3 @@ def geometry(self, mesh_size: MM | None = None) -> Geometry: geom = Geometry(geom=self.polygon) geom.create_mesh(mesh_sizes=mesh_size) return geom - - -if __name__ == "__main__": - """Example usage with plot.""" - import matplotlib.pyplot as plt - - # Create a circular cornered cross-section - section = CircularCorneredCrossSection( - thickness_vertical=15, - thickness_horizontal=40, - inner_radius=0, - outer_radius=6, - inner_slope_at_vertical=0, - inner_slope_at_horizontal=0, - outer_slope_at_vertical=8, - outer_slope_at_horizontal=0, - x=0, - y=0, - corner_direction=0, - ) - - # Get the geometry - geom = section.geometry() - - # Plot the cross-section - fig, ax = plt.subplots(figsize=(8, 8)) - x, y = section.polygon.exterior.xy - ax.plot(x, y, "b-", linewidth=2, label="Cross-section outline") - ax.fill(x, y, alpha=0.3) - ax.set_xlabel("x [mm]") - ax.set_ylabel("y [mm]") - ax.set_title("Circular Cornered Cross-Section") - ax.axis("equal") - ax.grid(True, alpha=0.3) - ax.legend() - plt.tight_layout() - plt.show() From 3cc4ed87e084a54498e4d3ec4078451bf606ca26 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 18:03:24 +0100 Subject: [PATCH 11/23] Add UNP profile fixture for testing in conftest.py --- .../steel/steel_cross_sections/conftest.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/structural_sections/steel/steel_cross_sections/conftest.py b/tests/structural_sections/steel/steel_cross_sections/conftest.py index e576e177b..8ffe27868 100644 --- a/tests/structural_sections/steel/steel_cross_sections/conftest.py +++ b/tests/structural_sections/steel/steel_cross_sections/conftest.py @@ -19,7 +19,9 @@ from blueprints.structural_sections.steel.steel_cross_sections.standard_profiles.lnp import LNP from blueprints.structural_sections.steel.steel_cross_sections.standard_profiles.rhs import RHS from blueprints.structural_sections.steel.steel_cross_sections.standard_profiles.strip import Strip +from blueprints.structural_sections.steel.steel_cross_sections.standard_profiles.unp import UNP from blueprints.structural_sections.steel.steel_cross_sections.strip_profile import StripSteelProfile +from blueprints.structural_sections.steel.steel_cross_sections.unp_profile import UNPSteelProfile @pytest.fixture @@ -90,3 +92,11 @@ def lnp_profile() -> LNPProfile: steel_material=SteelMaterial(steel_class), corrosion=0, ) + + +@pytest.fixture +def unp_profile() -> UNPSteelProfile: + """Fixture to set up a UNP profile for testing.""" + profile = UNP.UNP300 + steel_class = SteelStrengthClass.S355 + return UNPSteelProfile.from_standard_profile(profile=profile, steel_material=SteelMaterial(steel_class), corrosion=0) From a14db1d0ac798d95e06530f930a538db4cfa6688 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 18:03:44 +0100 Subject: [PATCH 12/23] Add test suite for UNPSteelProfile with comprehensive geometry and plotting tests --- .../steel_cross_sections/test_unp_profile.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/structural_sections/steel/steel_cross_sections/test_unp_profile.py diff --git a/tests/structural_sections/steel/steel_cross_sections/test_unp_profile.py b/tests/structural_sections/steel/steel_cross_sections/test_unp_profile.py new file mode 100644 index 000000000..3d5e9ac27 --- /dev/null +++ b/tests/structural_sections/steel/steel_cross_sections/test_unp_profile.py @@ -0,0 +1,76 @@ +"""Test suite for UNPSteelProfile.""" + +import matplotlib as mpl + +mpl.use("Agg") + +from unittest.mock import MagicMock + +import pytest +from matplotlib import pyplot as plt +from matplotlib.figure import Figure + +from blueprints.codes.eurocode.en_1993_1_1_2005.chapter_3_materials.table_3_1 import SteelStrengthClass +from blueprints.materials.steel import SteelMaterial +from blueprints.structural_sections.steel.steel_cross_sections.standard_profiles.unp import UNP +from blueprints.structural_sections.steel.steel_cross_sections.unp_profile import UNPSteelProfile + + +class TestUNPSteelProfile: + """Test suite for UNPSteelProfile.""" + + def test_alias(self, unp_profile: UNPSteelProfile) -> None: + """Test the alias of the UNP-profile.""" + expected_alias = "UNP300" + assert unp_profile.name == expected_alias + + def test_steel_volume_per_meter(self, unp_profile: UNPSteelProfile) -> None: + """Test the steel volume per meter.""" + expected_volume = 5.880e-3 # m³/m + assert pytest.approx(unp_profile.volume_per_meter, rel=1e-2) == expected_volume + + def test_steel_weight_per_meter(self, unp_profile: UNPSteelProfile) -> None: + """Test the steel weight per meter.""" + expected_weight = 5.880e-3 * 7850 # kg/m + assert pytest.approx(unp_profile.weight_per_meter, rel=1e-2) == expected_weight + + def test_steel_area(self, unp_profile: UNPSteelProfile) -> None: + """Test the steel cross-sectional area.""" + expected_area = 5.880e3 # mm² + assert pytest.approx(unp_profile.area, rel=1e-2) == expected_area + + @pytest.mark.slow + def test_plot_unp_profile(self, unp_profile: UNPSteelProfile) -> None: + """Test the plot method for a UNP profile (ensure it runs without errors).""" + fig: Figure = unp_profile.plot() + assert isinstance(fig, plt.Figure) + + def test_plot_mocked(self, unp_profile: UNPSteelProfile, mock_section_properties: MagicMock) -> None: # noqa: ARG002 + """Test the plotting of the UNP-profile shapes with mocked section properties.""" + fig: Figure = unp_profile.plot() + assert isinstance(fig, plt.Figure) + + def test_geometry(self, unp_profile: UNPSteelProfile) -> None: + """Test the geometry of the UNP profile.""" + expected_geometry = unp_profile.geometry + assert expected_geometry is not None + + def test_get_profile_with_corrosion(self) -> None: + """Test the UNP profile with 20 mm corrosion applied.""" + # Ensure the profile raises an error if fully corroded + with pytest.raises(ValueError, match=r"The profile has fully corroded."): + UNPSteelProfile.from_standard_profile( + profile=UNP.UNP300, + steel_material=SteelMaterial(SteelStrengthClass.S355), + corrosion=20, # mm + ) + + def test_corrosion_in_name(self) -> None: + """Test that the name includes corrosion information.""" + unp_profile_with_corrosion = UNPSteelProfile.from_standard_profile( + profile=UNP.UNP300, + steel_material=SteelMaterial(SteelStrengthClass.S355), + corrosion=2, # mm + ) + expected_name_with_corrosion = "UNP300 (corrosion: 2 mm)" + assert unp_profile_with_corrosion.name == expected_name_with_corrosion From d7a7eba57162a2f178d25bbb35f6989c06ff091d Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 18:11:14 +0100 Subject: [PATCH 13/23] Refactor UNPSteelProfile: update toe radius calculations to prevent impossible geometry with maximum corrosion --- .../steel/steel_cross_sections/unp_profile.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py index 439380f5e..6b9e931dd 100644 --- a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py +++ b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py @@ -136,10 +136,9 @@ def __post_init__(self) -> None: y=0, ) - # using modelled toe radius to avoid impossible geometry with small mesh - modelled_top_toe_radius = ( - min(self.top_toe_radius, 0.95 * top_thickness_at_toe) if min(self.top_toe_radius, 0.95 * top_thickness_at_toe) >= 1.0 else 0 - ) + # because toe radius gets interrupted by top of flange when corrosion is near maximum + # we use a modelled top toe radius to avoid impossible geometry + modelled_top_toe_radius = min(self.top_toe_radius, top_thickness_at_toe) if min(self.top_toe_radius, top_thickness_at_toe) >= 1.0 else 0 self.top_flange = CircularCorneredCrossSection( name="Top flange", inner_radius=0, @@ -152,9 +151,10 @@ def __post_init__(self) -> None: outer_slope_at_vertical=self.top_slope, ) - # using modelled toe radius to avoid impossible geometry with small mesh + # because toe radius gets interrupted by top of flange when corrosion is near maximum + # we use a modelled top toe radius to avoid impossible geometry modelled_bottom_toe_radius = ( - min(self.bottom_toe_radius, 0.95 * bottom_thickness_at_toe) if min(self.bottom_toe_radius, 0.95 * bottom_thickness_at_toe) >= 1.0 else 0 + min(self.bottom_toe_radius, bottom_thickness_at_toe) if min(self.bottom_toe_radius, bottom_thickness_at_toe) >= 1.0 else 0 ) self.bottom_flange = CircularCorneredCrossSection( name="Bottom flange", @@ -277,3 +277,10 @@ def plot(self, plotter: Callable[[CombinedSteelCrossSection], plt.Figure] | None *args, **kwargs, ) + + +if __name__ == "__main__": + # Example: UNP140 profile + unp140 = UNPSteelProfile.from_standard_profile(profile=UNP.UNP140, steel_material=SteelMaterial(), corrosion=3.45) + unp140.plot() + plt.show() From d178b6e90aaf3337a31bcc50d403986bc811d1da Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 18:13:39 +0100 Subject: [PATCH 14/23] Fix slope parameter type in slope_to_angle function and update nominal thickness assignment in UNPSteelProfile --- blueprints/math_helpers.py | 2 +- .../steel/steel_cross_sections/unp_profile.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/blueprints/math_helpers.py b/blueprints/math_helpers.py index 19d9fa097..b04f35fa0 100644 --- a/blueprints/math_helpers.py +++ b/blueprints/math_helpers.py @@ -65,7 +65,7 @@ def slope_to_angle(slope: PERCENTAGE) -> DEG: Parameters ---------- - slope : DIMENSIONLESS + slope : PERCENTAGE Slope as a percentage. Returns diff --git a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py index 6b9e931dd..1e38ee13a 100644 --- a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py +++ b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py @@ -173,7 +173,7 @@ def __post_init__(self) -> None: SteelElement( cross_section=self.corner_top, material=self.steel_material, - nominal_thickness=self.bottom_flange_thickness, + nominal_thickness=self.top_flange_thickness, ), SteelElement( cross_section=self.corner_bottom, From 2bf5e7794600b898a0620fc5ef19180f68ddd1bf Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 18:14:09 +0100 Subject: [PATCH 15/23] Remove example usage from UNPSteelProfile for cleaner module interface --- .../steel/steel_cross_sections/unp_profile.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py index 1e38ee13a..52f40443a 100644 --- a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py +++ b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py @@ -277,10 +277,3 @@ def plot(self, plotter: Callable[[CombinedSteelCrossSection], plt.Figure] | None *args, **kwargs, ) - - -if __name__ == "__main__": - # Example: UNP140 profile - unp140 = UNPSteelProfile.from_standard_profile(profile=UNP.UNP140, steel_material=SteelMaterial(), corrosion=3.45) - unp140.plot() - plt.show() From 4517afae913329cfce67d4072b49f148b001fad7 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 18:16:58 +0100 Subject: [PATCH 16/23] Add tests for invalid reference points and slope angles in CircularCorneredCrossSection --- .../test_cross_section_cornered.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/structural_sections/test_cross_section_cornered.py b/tests/structural_sections/test_cross_section_cornered.py index 1180bec61..83a94cf44 100644 --- a/tests/structural_sections/test_cross_section_cornered.py +++ b/tests/structural_sections/test_cross_section_cornered.py @@ -54,3 +54,27 @@ def test_invalid_corner_direction(self) -> None: outer_radius=10, corner_direction=4, ) + + def test_invalid_reference_point(self) -> None: + """Test initialization with an invalid reference point.""" + with pytest.raises(ValueError, match="reference_point must be one of 'bottom_left', 'bottom_right', 'top_left', or 'top_right', got center"): + CircularCorneredCrossSection( + thickness_vertical=10, + thickness_horizontal=10, + inner_radius=5, + outer_radius=10, + reference_point="reference_point_that_does_not_exist", + ) + + def test_invalid_slope_angle(self) -> None: + """Test initialization with invalid slope angles.""" + with pytest.raises( + ValueError, match="Sum of inner_slope_at_vertical and inner_slope_at_horizontal must be less than 90 degrees. Got 100.0 degrees." + ): + CircularCorneredCrossSection( + thickness_vertical=10, + thickness_horizontal=10, + inner_radius=5, + outer_radius=10, + inner_slope_at_vertical=683, + ) From 69900cd7d56ca134cd21c65e62bd40983b6ec915 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 18:17:04 +0100 Subject: [PATCH 17/23] Remove redundant validation for extensions in CircularCorneredCrossSection --- blueprints/structural_sections/cross_section_cornered.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/blueprints/structural_sections/cross_section_cornered.py b/blueprints/structural_sections/cross_section_cornered.py index cdf28916b..681026d10 100644 --- a/blueprints/structural_sections/cross_section_cornered.py +++ b/blueprints/structural_sections/cross_section_cornered.py @@ -216,9 +216,6 @@ def polygon(self) -> Polygon: if all(x >= 0 for x in [i_a_ext_at_horizontal, i_a_ext_at_vertical, o_a_ext_at_horizontal, o_a_ext_at_vertical]): break - if not all(x >= 0 for x in [i_a_ext_at_horizontal, i_a_ext_at_vertical, o_a_ext_at_horizontal, o_a_ext_at_vertical]): - raise ValueError("Could not determine valid extensions to align inner and outer arcs.") - total_width = ( o_a_width + o_a_ext_at_horizontal * np.sin(self.outer_angle_at_horizontal) + o_a_ext_at_vertical * np.cos(self.outer_angle_at_vertical) ) From 0fd8484d8a249bf2b5bb784ec13c28f1f4fdbb38 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 18:33:25 +0100 Subject: [PATCH 18/23] Refactor extension calculation logic in CircularCorneredCrossSection for clarity and efficiency --- .../cross_section_cornered.py | 71 ++++++------------- 1 file changed, 23 insertions(+), 48 deletions(-) diff --git a/blueprints/structural_sections/cross_section_cornered.py b/blueprints/structural_sections/cross_section_cornered.py index 681026d10..be13b8170 100644 --- a/blueprints/structural_sections/cross_section_cornered.py +++ b/blueprints/structural_sections/cross_section_cornered.py @@ -167,54 +167,29 @@ def polygon(self) -> Polygon: # = o_a_height + o_a_ext_h*cos(o_angle_h) + o_a_ext_v*sin(o_angle_v) # # Four unknowns: i_a_ext_h, i_a_ext_v, o_a_ext_h, o_a_ext_v - # Two of them are zero, two of them are positive - # Currently there is no one solid way to determine which two are zero, so we try all four combinations - - # Try strategies: (outer_h_zero, outer_v_zero, inner_h_zero, inner_v_zero) - for o_h_z, o_v_z, i_h_z, i_v_z in [(1, 1, 0, 0), (0, 0, 1, 1), (1, 0, 0, 1), (0, 1, 1, 0)]: - if o_h_z and o_v_z: - a = np.array( - [ - [np.sin(self.inner_angle_at_horizontal), np.cos(self.inner_angle_at_vertical)], - [np.cos(self.inner_angle_at_horizontal), np.sin(self.inner_angle_at_vertical)], - ] - ) - b = np.array([o_a_width - i_a_width - self.thickness_horizontal, o_a_height - i_a_height - self.thickness_vertical]) - i_a_ext_at_horizontal, i_a_ext_at_vertical = np.linalg.solve(a, b) - o_a_ext_at_horizontal = o_a_ext_at_vertical = 0 - elif i_h_z and i_v_z: - a = np.array( - [ - [np.sin(self.outer_angle_at_horizontal), np.cos(self.outer_angle_at_vertical)], - [np.cos(self.outer_angle_at_horizontal), np.sin(self.outer_angle_at_vertical)], - ] - ) - b = np.array([i_a_width + self.thickness_horizontal - o_a_width, i_a_height + self.thickness_vertical - o_a_height]) - o_a_ext_at_horizontal, o_a_ext_at_vertical = np.linalg.solve(a, b) - i_a_ext_at_horizontal = i_a_ext_at_vertical = 0 - elif o_h_z and i_v_z: - a = np.array( - [ - [np.sin(self.inner_angle_at_horizontal), np.cos(self.outer_angle_at_vertical)], - [np.cos(self.inner_angle_at_horizontal), np.sin(self.outer_angle_at_vertical)], - ] - ) - b = np.array([o_a_width - i_a_width - self.thickness_horizontal, o_a_height - i_a_height - self.thickness_vertical]) - i_a_ext_at_horizontal, o_a_ext_at_vertical = np.linalg.solve(a, b) - o_a_ext_at_horizontal = i_a_ext_at_vertical = 0 - else: - a = np.array( - [ - [np.sin(self.outer_angle_at_horizontal), np.cos(self.inner_angle_at_vertical)], - [np.cos(self.outer_angle_at_horizontal), np.sin(self.inner_angle_at_vertical)], - ] - ) - b = np.array([o_a_width - i_a_width - self.thickness_horizontal, o_a_height - i_a_height - self.thickness_vertical]) - o_a_ext_at_horizontal, i_a_ext_at_vertical = np.linalg.solve(a, b) - i_a_ext_at_horizontal = o_a_ext_at_vertical = 0 - - if all(x >= 0 for x in [i_a_ext_at_horizontal, i_a_ext_at_vertical, o_a_ext_at_horizontal, o_a_ext_at_vertical]): - break + # Either the inner arcs need extension, or the outer arcs do. + a = np.array( + [ + [np.sin(self.inner_angle_at_horizontal), np.cos(self.inner_angle_at_vertical)], + [np.cos(self.inner_angle_at_horizontal), np.sin(self.inner_angle_at_vertical)], + ] + ) + b = np.array([o_a_width - i_a_width - self.thickness_horizontal, o_a_height - i_a_height - self.thickness_vertical]) + i_a_ext_at_horizontal, i_a_ext_at_vertical = np.linalg.solve(a, b) + + if i_a_ext_at_horizontal >= 0 and i_a_ext_at_vertical >= 0: + o_a_ext_at_horizontal = o_a_ext_at_vertical = 0 + + else: + a = np.array( + [ + [np.sin(self.outer_angle_at_horizontal), np.cos(self.outer_angle_at_vertical)], + [np.cos(self.outer_angle_at_horizontal), np.sin(self.outer_angle_at_vertical)], + ] + ) + b = np.array([i_a_width + self.thickness_horizontal - o_a_width, i_a_height + self.thickness_vertical - o_a_height]) + o_a_ext_at_horizontal, o_a_ext_at_vertical = np.linalg.solve(a, b) + i_a_ext_at_horizontal = i_a_ext_at_vertical = 0 total_width = ( o_a_width + o_a_ext_at_horizontal * np.sin(self.outer_angle_at_horizontal) + o_a_ext_at_vertical * np.cos(self.outer_angle_at_vertical) From 9f8d8fc777c019ff7c6e256e25e51a98422277f8 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 18:33:36 +0100 Subject: [PATCH 19/23] Update error messages in tests for CircularCorneredCrossSection and add extension calculation test --- .../test_cross_section_cornered.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/structural_sections/test_cross_section_cornered.py b/tests/structural_sections/test_cross_section_cornered.py index 83a94cf44..3463d6014 100644 --- a/tests/structural_sections/test_cross_section_cornered.py +++ b/tests/structural_sections/test_cross_section_cornered.py @@ -57,7 +57,7 @@ def test_invalid_corner_direction(self) -> None: def test_invalid_reference_point(self) -> None: """Test initialization with an invalid reference point.""" - with pytest.raises(ValueError, match="reference_point must be one of 'bottom_left', 'bottom_right', 'top_left', or 'top_right', got center"): + with pytest.raises(ValueError, match="reference_point must be either 'intersection' or 'outer', got reference_point_that_does_not_exist"): CircularCorneredCrossSection( thickness_vertical=10, thickness_horizontal=10, @@ -68,9 +68,7 @@ def test_invalid_reference_point(self) -> None: def test_invalid_slope_angle(self) -> None: """Test initialization with invalid slope angles.""" - with pytest.raises( - ValueError, match="Sum of inner_slope_at_vertical and inner_slope_at_horizontal must be less than 90 degrees. Got 100.0 degrees." - ): + with pytest.raises(ValueError, match="All slopes must be less than 100%"): CircularCorneredCrossSection( thickness_vertical=10, thickness_horizontal=10, @@ -78,3 +76,15 @@ def test_invalid_slope_angle(self) -> None: outer_radius=10, inner_slope_at_vertical=683, ) + + def test_extensions(self) -> None: + """Test that extensions are calculated correctly.""" + cross_section = CircularCorneredCrossSection( + thickness_vertical=2, + thickness_horizontal=2, + inner_radius=0, + outer_radius=10, + ) + # Accessing the polygon property to trigger extension calculations + _ = cross_section.polygon + # If no exception is raised, the test passes From b3762613666829c6d1eaed9bfacfa9a1d9865047 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Sun, 16 Nov 2025 18:35:56 +0100 Subject: [PATCH 20/23] Fix typos in comments for CircularCorneredCrossSection and UNPSteelProfile classes --- blueprints/structural_sections/cross_section_cornered.py | 2 +- .../steel/steel_cross_sections/unp_profile.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/blueprints/structural_sections/cross_section_cornered.py b/blueprints/structural_sections/cross_section_cornered.py index be13b8170..421c13dfe 100644 --- a/blueprints/structural_sections/cross_section_cornered.py +++ b/blueprints/structural_sections/cross_section_cornered.py @@ -158,7 +158,7 @@ def polygon(self) -> Polygon: i_a_width = np.max(inner_arc[:, 0]) - np.min(inner_arc[:, 0]) i_a_height = np.max(inner_arc[:, 1]) - np.min(inner_arc[:, 1]) - # Based on input its possible that either the outer arc or the inner arc is wider/taller + # Based on input it's possible that either the outer arc or the inner arc is wider/taller # than the other (plus thickness). To align them, we need to extend one of the arcs. # Solve for extensions to make inner and outer arcs align # Full system of equations: i_a_width + thickness_horizontal + i_a_ext_h*sin(i_angle_h) diff --git a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py index 52f40443a..a61d8f7ea 100644 --- a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py +++ b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py @@ -85,7 +85,7 @@ def __post_init__(self) -> None: # Create curves for the corners of the flanges # It is used that the thickness is measured vertically halfway the total width of the flange - # The results of this allign with standard UNP profiles databases + # The results of this align with standard UNP profiles databases top_angle = np.deg2rad(slope_to_angle(self.top_slope)) bottom_angle = np.deg2rad(slope_to_angle(self.bottom_slope)) From 8f7bfe2c70ec88f04cfd408c93a1f19452dd95e4 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Mon, 17 Nov 2025 10:26:52 +0100 Subject: [PATCH 21/23] Refactor extension calculation in CircularCorneredCrossSection to improve logic and handle corrosion effects on arc points --- .../cross_section_cornered.py | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/blueprints/structural_sections/cross_section_cornered.py b/blueprints/structural_sections/cross_section_cornered.py index 421c13dfe..074f79be7 100644 --- a/blueprints/structural_sections/cross_section_cornered.py +++ b/blueprints/structural_sections/cross_section_cornered.py @@ -160,26 +160,16 @@ def polygon(self) -> Polygon: # Based on input it's possible that either the outer arc or the inner arc is wider/taller # than the other (plus thickness). To align them, we need to extend one of the arcs. - # Solve for extensions to make inner and outer arcs align - # Full system of equations: i_a_width + thickness_horizontal + i_a_ext_h*sin(i_angle_h) - # + i_a_ext_v*cos(i_angle_v) = o_a_width + o_a_ext_h*sin(o_angle_h) + o_a_ext_v*cos(o_angle_v) - # and i_a_height + thickness_vertical + i_a_ext_h*cos(i_angle_h) + i_a_ext_v*sin(i_angle_v) - # = o_a_height + o_a_ext_h*cos(o_angle_h) + o_a_ext_v*sin(o_angle_v) - # - # Four unknowns: i_a_ext_h, i_a_ext_v, o_a_ext_h, o_a_ext_v - # Either the inner arcs need extension, or the outer arcs do. - a = np.array( - [ - [np.sin(self.inner_angle_at_horizontal), np.cos(self.inner_angle_at_vertical)], - [np.cos(self.inner_angle_at_horizontal), np.sin(self.inner_angle_at_vertical)], - ] - ) - b = np.array([o_a_width - i_a_width - self.thickness_horizontal, o_a_height - i_a_height - self.thickness_vertical]) - i_a_ext_at_horizontal, i_a_ext_at_vertical = np.linalg.solve(a, b) - - if i_a_ext_at_horizontal >= 0 and i_a_ext_at_vertical >= 0: + if o_a_width > i_a_width + self.thickness_horizontal and o_a_height > i_a_height + self.thickness_vertical: + a = np.array( + [ + [np.sin(self.inner_angle_at_horizontal), np.cos(self.inner_angle_at_vertical)], + [np.cos(self.inner_angle_at_horizontal), np.sin(self.inner_angle_at_vertical)], + ] + ) + b = np.array([o_a_width - i_a_width - self.thickness_horizontal, o_a_height - i_a_height - self.thickness_vertical]) + i_a_ext_at_horizontal, i_a_ext_at_vertical = np.linalg.solve(a, b) o_a_ext_at_horizontal = o_a_ext_at_vertical = 0 - else: a = np.array( [ @@ -198,7 +188,7 @@ def polygon(self) -> Polygon: o_a_height + o_a_ext_at_horizontal * np.cos(self.outer_angle_at_horizontal) + o_a_ext_at_vertical * np.sin(self.outer_angle_at_vertical) ) - # Translate outer arc points + # Translate outer arc points and allow for corrosion resulting in sharper angle outer_arc[:, 0] += o_a_ext_at_vertical * np.cos(self.outer_angle_at_vertical) - np.min(outer_arc[:, 0]) outer_arc[:, 1] += o_a_ext_at_horizontal * np.cos(self.outer_angle_at_horizontal) - np.min(outer_arc[:, 1]) @@ -219,6 +209,18 @@ def polygon(self) -> Polygon: ] ) + # Remove points that are beyond the axes if corrosion has resulted in sharper than profile usually has + if o_a_ext_at_horizontal < 0: + # Find x-coordinate where outer arc crosses y=0 using interpolation between least negative and least positive y + x_at_zero = np.interp(0, points[:, 1], points[:, 0]) + points = np.vstack([[x_at_zero, 0], points[points[:, 1] >= 0]]) + points = points[points[:, 0] <= x_at_zero] + if o_a_ext_at_vertical < 0: + # Find y-coordinate where outer arc crosses x=0 using interpolation between least negative and least positive x + y_at_zero = np.interp(0, points[:, 0], points[:, 1]) + points = np.vstack([[0, y_at_zero], points[points[:, 0] >= 0]]) + points = points[points[:, 1] <= y_at_zero] + # Remove consecutive duplicate points mask = np.any(np.diff(points, axis=0) != 0, axis=1) points = points[np.insert(mask, 0, True)] From f6299860240c2750fe3a6db63b31d539bede3b09 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Mon, 17 Nov 2025 10:27:40 +0100 Subject: [PATCH 22/23] Refactor toe radius handling in UNPSteelProfile to use actual values for outer radius in flange calculations --- .../steel/steel_cross_sections/unp_profile.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py index a61d8f7ea..715dcf15c 100644 --- a/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py +++ b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py @@ -100,9 +100,6 @@ def __post_init__(self) -> None: / 100 ) - top_thickness_at_toe = max(0, self.top_flange_thickness - (self.top_flange_total_width / 2) * self.top_slope / 100) - bottom_thickness_at_toe = max(0, self.bottom_flange_thickness - (self.bottom_flange_total_width / 2) * self.bottom_slope / 100) - self.corner_top = CircularCorneredCrossSection( name="Corner top", inner_radius=self.top_root_fillet_radius, @@ -136,13 +133,10 @@ def __post_init__(self) -> None: y=0, ) - # because toe radius gets interrupted by top of flange when corrosion is near maximum - # we use a modelled top toe radius to avoid impossible geometry - modelled_top_toe_radius = min(self.top_toe_radius, top_thickness_at_toe) if min(self.top_toe_radius, top_thickness_at_toe) >= 1.0 else 0 self.top_flange = CircularCorneredCrossSection( name="Top flange", inner_radius=0, - outer_radius=modelled_top_toe_radius, + outer_radius=self.top_toe_radius, x=self.corner_top.total_width, y=self.total_height / 2, corner_direction=3, @@ -151,15 +145,10 @@ def __post_init__(self) -> None: outer_slope_at_vertical=self.top_slope, ) - # because toe radius gets interrupted by top of flange when corrosion is near maximum - # we use a modelled top toe radius to avoid impossible geometry - modelled_bottom_toe_radius = ( - min(self.bottom_toe_radius, bottom_thickness_at_toe) if min(self.bottom_toe_radius, bottom_thickness_at_toe) >= 1.0 else 0 - ) self.bottom_flange = CircularCorneredCrossSection( name="Bottom flange", inner_radius=0, - outer_radius=modelled_bottom_toe_radius, + outer_radius=self.bottom_toe_radius, x=self.corner_bottom.total_width, y=-self.total_height / 2, corner_direction=0, From 159a726aa7f9fdbc0d3dd19fd1aa8041935dec01 Mon Sep 17 00:00:00 2001 From: GerjanDorgelo Date: Mon, 17 Nov 2025 11:16:21 +0100 Subject: [PATCH 23/23] Add handling for extreme corrosion cases in CircularCorneredCrossSection and corresponding test --- .../cross_section_cornered.py | 21 ++++++------ .../test_cross_section_cornered.py | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/blueprints/structural_sections/cross_section_cornered.py b/blueprints/structural_sections/cross_section_cornered.py index 074f79be7..a993ea639 100644 --- a/blueprints/structural_sections/cross_section_cornered.py +++ b/blueprints/structural_sections/cross_section_cornered.py @@ -192,6 +192,15 @@ def polygon(self) -> Polygon: outer_arc[:, 0] += o_a_ext_at_vertical * np.cos(self.outer_angle_at_vertical) - np.min(outer_arc[:, 0]) outer_arc[:, 1] += o_a_ext_at_horizontal * np.cos(self.outer_angle_at_horizontal) - np.min(outer_arc[:, 1]) + # heavy corrosion of for example UNP-elements might lead to situations where the toe radius corrodes + # into the flat side of the flange. This results in a non-90 degree corner + if o_a_ext_at_horizontal < 0: + x_at_y_is_zero = np.interp(0, outer_arc[:, 1], outer_arc[:, 0]) + outer_arc = np.vstack([[x_at_y_is_zero, 0], outer_arc[outer_arc[:, 1] >= 0]]) + if o_a_ext_at_vertical < 0: + y_at_x_is_zero = np.interp(0, outer_arc[:, 0][::-1], outer_arc[:, 1][::-1]) + outer_arc = np.vstack([outer_arc[outer_arc[:, 0] >= 0], [0, y_at_x_is_zero]]) + # Translate inner arc points inner_arc[:, 0] += i_a_ext_at_vertical * np.cos(self.inner_angle_at_vertical) - np.min(inner_arc[:, 0]) inner_arc[:, 1] += i_a_ext_at_horizontal * np.cos(self.inner_angle_at_horizontal) - np.min(inner_arc[:, 1]) @@ -209,17 +218,11 @@ def polygon(self) -> Polygon: ] ) - # Remove points that are beyond the axes if corrosion has resulted in sharper than profile usually has + # Remove redundant points if corrosion has removed part of the arc if o_a_ext_at_horizontal < 0: - # Find x-coordinate where outer arc crosses y=0 using interpolation between least negative and least positive y - x_at_zero = np.interp(0, points[:, 1], points[:, 0]) - points = np.vstack([[x_at_zero, 0], points[points[:, 1] >= 0]]) - points = points[points[:, 0] <= x_at_zero] + points = points[points[:, 0] <= x_at_y_is_zero] if o_a_ext_at_vertical < 0: - # Find y-coordinate where outer arc crosses x=0 using interpolation between least negative and least positive x - y_at_zero = np.interp(0, points[:, 0], points[:, 1]) - points = np.vstack([[0, y_at_zero], points[points[:, 0] >= 0]]) - points = points[points[:, 1] <= y_at_zero] + points = points[points[:, 1] <= y_at_x_is_zero] # Remove consecutive duplicate points mask = np.any(np.diff(points, axis=0) != 0, axis=1) diff --git a/tests/structural_sections/test_cross_section_cornered.py b/tests/structural_sections/test_cross_section_cornered.py index 3463d6014..0c62175aa 100644 --- a/tests/structural_sections/test_cross_section_cornered.py +++ b/tests/structural_sections/test_cross_section_cornered.py @@ -88,3 +88,35 @@ def test_extensions(self) -> None: # Accessing the polygon property to trigger extension calculations _ = cross_section.polygon # If no exception is raised, the test passes + + def test_extreme_corrosion(self) -> None: + """Test handling of extreme corrosion cases leading to non-90 degree angles.""" + corner_section = CircularCorneredCrossSection( + thickness_vertical=15, + thickness_horizontal=30, + inner_radius=0, + outer_radius=25, + corner_direction=0, + inner_slope_at_vertical=0, + inner_slope_at_horizontal=0, + outer_slope_at_vertical=8, + outer_slope_at_horizontal=0, + ) + + # Accessing the polygon property to trigger extension calculations + _ = corner_section.polygon + + corner_section = CircularCorneredCrossSection( + thickness_vertical=30, + thickness_horizontal=15, + inner_radius=0, + outer_radius=25, + corner_direction=0, + inner_slope_at_vertical=0, + inner_slope_at_horizontal=0, + outer_slope_at_vertical=0, + outer_slope_at_horizontal=8, + ) + + # Accessing the polygon property to trigger extension calculations + _ = corner_section.polygon