diff --git a/blueprints/math_helpers.py b/blueprints/math_helpers.py index 4138fff70..b04f35fa0 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 : PERCENTAGE + 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/blueprints/structural_sections/cross_section_cornered.py b/blueprints/structural_sections/cross_section_cornered.py index e4404357e..a993ea639 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, RAD from blueprints.validations import raise_if_negative @@ -16,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 - ∨ - . . .+-----------------------+ + .---- outer reference point + | + | .---- outer arc .---- o_a_ext_at_vertical + v ∨ v + x . .+-----------------------+ . ⁄ | .⁄ |<-- thickness_vertical + | @@ -26,7 +29,9 @@ class CircularCorneredCrossSection(CrossSection): | / | / | | - +-------------------+<-- thickness_horizontal + +-------------------+ x-- intersection reference point + ^ + .---- thickness_horizontal Parameters ---------- @@ -40,10 +45,23 @@ 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) + x-coordinate of reference point y : MM - y-coordinate of the center of the inner_radius (default 0) + 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") """ @@ -52,9 +70,14 @@ 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 + reference_point: str = "intersection" x: MM = 0 y: MM = 0 - corner_direction: int = 0 # 0 = ↰, 1 = ↱, 2 = ↳, 3 = ↲ name: str = "Corner" def __post_init__(self) -> None: @@ -64,76 +87,160 @@ 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( - 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.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( + 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 width_rectangle(self) -> MM: - """Width of the rectangle part of the corner cross-section [mm].""" - return self.thickness_horizontal + self.inner_radius + 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 height_rectangle(self) -> MM: - """Height of the rectangle part of the corner cross-section [mm].""" - return self.thickness_vertical + self.inner_radius + 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) -> 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) -> 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.""" - lr = (self.x + self.width_rectangle, self.y) - ul = (self.x, self.y + self.height_rectangle) - n = 16 # Outer arc (from vertical to horizontal) - theta_outer = np.linspace(0, np.pi / 2, n) 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(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(0, np.pi / 2, 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(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]) + i_a_height = np.max(inner_arc[:, 1]) - np.min(inner_arc[:, 1]) + + # 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. + 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( + [ + [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) + ) + 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 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]) + + # 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]) # Combine points - points = np.vstack([lr, outer_arc, ul, 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] + # Remove redundant points if corrosion has removed part of the arc + if o_a_ext_at_horizontal < 0: + points = points[points[:, 0] <= x_at_y_is_zero] + if o_a_ext_at_vertical < 0: + points = points[points[:, 1] <= y_at_x_is_zero] - # Create transformation matrices for flipping - flip_x = np.array([[-1, 0], [0, 1]]) - flip_y = np.array([[1, 0], [0, -1]]) + # Remove consecutive duplicate points + mask = np.any(np.diff(points, axis=0) != 0, axis=1) + points = points[np.insert(mask, 0, True)] - # Center points around (self.x, self.y) - points_centered = points - np.array([self.x, self.y]) + # Shift points to make outer reference point at (x, y) + if self.reference_point == "outer": + points[:, 0] -= total_width + points[:, 1] -= total_height # Apply flips based on corner_direction if self.corner_direction in (1, 2): - points_centered = points_centered @ flip_x + points = points @ np.array([[-1, 0], [0, 1]]) 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 @ np.array([[1, 0], [0, -1]]) + # 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)) 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..715dcf15c --- /dev/null +++ b/blueprints/structural_sections/steel/steel_cross_sections/unp_profile.py @@ -0,0 +1,268 @@ +"""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 used that the thickness is measured vertically halfway the total width of the flange + # 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)) + + 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.top_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 + 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( + [ + 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=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, + ) + + 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, + ) 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) 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 diff --git a/tests/structural_sections/test_cross_section_cornered.py b/tests/structural_sections/test_cross_section_cornered.py index 7caf2efac..0c62175aa 100644 --- a/tests/structural_sections/test_cross_section_cornered.py +++ b/tests/structural_sections/test_cross_section_cornered.py @@ -23,37 +23,100 @@ 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) - - 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(**{**defaults, **kwargs}) + + 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"): CircularCorneredCrossSection( thickness_vertical=10, thickness_horizontal=10, inner_radius=5, - outer_radius=20, + outer_radius=10, + corner_direction=4, ) - 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"): + def test_invalid_reference_point(self) -> None: + """Test initialization with an invalid reference point.""" + 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, inner_radius=5, outer_radius=10, - corner_direction=4, + 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="All slopes must be less than 100%"): + CircularCorneredCrossSection( + thickness_vertical=10, + thickness_horizontal=10, + inner_radius=5, + 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 + + 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 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)