Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ecdfd9d
Add slope to angle and angle to slope conversion functions with tests
GerjanDorgelo Nov 15, 2025
1aae091
Add inner slope properties to CircularCorneredCrossSection
GerjanDorgelo Nov 15, 2025
cd15e1b
Remove redundant test for outer radius validation in CircularCornered…
GerjanDorgelo Nov 16, 2025
7ef8555
Refactor CircularCorneredCrossSection to improve parameter descriptio…
GerjanDorgelo Nov 16, 2025
c2dfa29
Refactor test for negative value validation in CircularCorneredCrossS…
GerjanDorgelo Nov 16, 2025
20b645b
Enhance CircularCorneredCrossSection: add total width and height prop…
GerjanDorgelo Nov 16, 2025
fab32e6
Add reference point parameter to CircularCorneredCrossSection and upd…
GerjanDorgelo Nov 16, 2025
50951b7
Add UNP-Profile steel section implementation with customizable attrib…
GerjanDorgelo Nov 16, 2025
590830a
Refactor UNPSteelProfile: improve flange thickness calculations and u…
GerjanDorgelo Nov 16, 2025
ca56c16
Add validation for arc extensions in CircularCorneredCrossSection and…
GerjanDorgelo Nov 16, 2025
3cc4ed8
Add UNP profile fixture for testing in conftest.py
GerjanDorgelo Nov 16, 2025
a14db1d
Add test suite for UNPSteelProfile with comprehensive geometry and pl…
GerjanDorgelo Nov 16, 2025
d7a7eba
Refactor UNPSteelProfile: update toe radius calculations to prevent i…
GerjanDorgelo Nov 16, 2025
1a6b169
Merge branch 'main' into 677-feature-request-add-steel-shapes-for-c-p…
GerjanDorgelo Nov 16, 2025
d178b6e
Fix slope parameter type in slope_to_angle function and update nomina…
GerjanDorgelo Nov 16, 2025
2bf5e77
Remove example usage from UNPSteelProfile for cleaner module interface
GerjanDorgelo Nov 16, 2025
cb5069d
Merge branch '677-feature-request-add-steel-shapes-for-c-profiles' of…
GerjanDorgelo Nov 16, 2025
4517afa
Add tests for invalid reference points and slope angles in CircularCo…
GerjanDorgelo Nov 16, 2025
69900cd
Remove redundant validation for extensions in CircularCorneredCrossSe…
GerjanDorgelo Nov 16, 2025
0fd8484
Refactor extension calculation logic in CircularCorneredCrossSection …
GerjanDorgelo Nov 16, 2025
9f8d8fc
Update error messages in tests for CircularCorneredCrossSection and a…
GerjanDorgelo Nov 16, 2025
b376261
Fix typos in comments for CircularCorneredCrossSection and UNPSteelPr…
GerjanDorgelo Nov 16, 2025
8f7bfe2
Refactor extension calculation in CircularCorneredCrossSection to imp…
GerjanDorgelo Nov 17, 2025
f629986
Refactor toe radius handling in UNPSteelProfile to use actual values …
GerjanDorgelo Nov 17, 2025
159a726
Add handling for extreme corrosion cases in CircularCorneredCrossSect…
GerjanDorgelo Nov 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion blueprints/math_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
195 changes: 151 additions & 44 deletions blueprints/structural_sections/cross_section_cornered.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -16,17 +17,21 @@ 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
+ |
| _ _ |<-- inner arc
| /
| /
| |
+-------------------+<-- thickness_horizontal
+-------------------+ x-- intersection reference point
^
.---- thickness_horizontal

Parameters
----------
Expand All @@ -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")
"""
Expand All @@ -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:
Expand All @@ -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))
Expand Down
Loading