Skip to content

Commit ae534cd

Browse files
Merge branch 'main' into 503-feature-request-add-formulas-617-622-from-nen_en_1993_1_1
2 parents e561c9d + 4c0af84 commit ae534cd

File tree

4 files changed

+624
-2
lines changed

4 files changed

+624
-2
lines changed

blueprints/structural_sections/_cross_section.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,12 @@ def elastic_section_modulus_about_z_negative(self) -> MM3:
7070

7171
@property
7272
@abstractmethod
73-
def plastic_section_modulus_about_y(self) -> MM3:
73+
def plastic_section_modulus_about_y(self) -> MM3 | None:
7474
"""Plastic section modulus about the y-axis [mm³]."""
7575

7676
@property
7777
@abstractmethod
78-
def plastic_section_modulus_about_z(self) -> MM3:
78+
def plastic_section_modulus_about_z(self) -> MM3 | None:
7979
"""Plastic section modulus about the z-axis [mm³]."""
8080

8181
@abstractmethod
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
"""Annular sector cross-section shape."""
2+
3+
import math
4+
from dataclasses import dataclass
5+
6+
import numpy as np
7+
from sectionproperties.pre import Geometry
8+
from shapely.affinity import rotate
9+
from shapely.geometry import Point, Polygon
10+
11+
from blueprints.structural_sections._cross_section import CrossSection
12+
from blueprints.type_alias import DEG, MM, MM2, MM3, MM4
13+
14+
15+
@dataclass(frozen=True)
16+
class AnnularSectorCrossSection(CrossSection):
17+
"""
18+
Class to represent an annular sector cross-section using shapely for geometric calculations.
19+
20+
Parameters
21+
----------
22+
inner_radius : MM
23+
The radius of the inner circle of the annular sector [mm].
24+
thickness : MM
25+
The thickness of the annular sector cross-section [mm].
26+
start_angle : DEG
27+
The start angle of the annular sector in degrees (top = 0 degrees, clockwise is positive).
28+
end_angle : DEG
29+
The end angle of the annular sector in degrees (must be larger than start angle but not more than 360 degrees more).
30+
x : MM
31+
The x-coordinate of the annular sector's radius center.
32+
y : MM
33+
The y-coordinate of the annular sector's radius center.
34+
name : str
35+
The name of the rectangular cross-section, default is "Annular Sector".
36+
"""
37+
38+
inner_radius: MM
39+
thickness: MM
40+
start_angle: DEG
41+
end_angle: DEG
42+
x: MM
43+
y: MM
44+
name: str = "Annular Sector"
45+
46+
def __post_init__(self) -> None:
47+
"""Post-initialization to validate the inputs."""
48+
if self.inner_radius < 0:
49+
raise ValueError(f"Radius must be zero or positive, but got {self.inner_radius}")
50+
if self.thickness <= 0:
51+
raise ValueError(f"Thickness must be a positive value, but got {self.thickness}")
52+
if self.start_angle > 360 or self.start_angle < -360:
53+
raise ValueError(f"Start angle must be between -360 and 360 degrees, but got {self.start_angle}")
54+
if self.end_angle <= self.start_angle:
55+
raise ValueError(f"End angle must be greater than start angle, but got end angle {self.end_angle} and start angle {self.start_angle}")
56+
if self.end_angle - self.start_angle >= 360:
57+
raise ValueError(
58+
f"The total angle made between start and end angle must be less than 360 degrees, but got "
59+
f"{self.end_angle - self.start_angle} degrees (end {self.end_angle} - start {self.start_angle})\n\n"
60+
f"In case you want to create a full circle (donut shape), "
61+
"use a tube cross section instead (TubeCrossSection)."
62+
)
63+
64+
@property
65+
def radius_centerline(self) -> MM:
66+
"""Calculate the inner radius of the annular sector [mm]."""
67+
return self.inner_radius + self.thickness / 2.0
68+
69+
@property
70+
def outer_radius(self) -> MM:
71+
"""Calculate the outer radius of the annular sector [mm]."""
72+
return self.radius_centerline + self.thickness / 2.0
73+
74+
@property
75+
def height(self) -> MM:
76+
"""
77+
Calculate the height of the annular sector cross-section [mm].
78+
79+
Returns
80+
-------
81+
MM
82+
The height of the annular sector.
83+
"""
84+
min_y = min(y for _, y in self.polygon.exterior.coords)
85+
max_y = max(y for _, y in self.polygon.exterior.coords)
86+
return max_y - min_y
87+
88+
@property
89+
def width(self) -> MM:
90+
"""
91+
Calculate the width of the annular sector cross-section [mm].
92+
93+
Returns
94+
-------
95+
MM
96+
The width of the annular sector.
97+
"""
98+
min_x = min(x for x, _ in self.polygon.exterior.coords)
99+
max_x = max(x for x, _ in self.polygon.exterior.coords)
100+
return max_x - min_x
101+
102+
@property
103+
def polygon(self) -> Polygon:
104+
"""
105+
Shapely Polygon representing the annular sector cross-section.
106+
107+
Returns
108+
-------
109+
Polygon
110+
The shapely Polygon representing the annular sector.
111+
"""
112+
center = Point(self.x, self.y)
113+
inner_circle = center.buffer(self.inner_radius, resolution=64)
114+
outer_circle = center.buffer(self.outer_radius, resolution=64)
115+
116+
inner_ring = rotate(inner_circle, -self.start_angle, origin=center)
117+
outer_ring = rotate(outer_circle, -self.start_angle, origin=center)
118+
119+
# Create the annular sector by intersecting with a sector
120+
sector_points = [center]
121+
angle_step = (self.end_angle - self.start_angle) / 8
122+
for i in range(9):
123+
angle = math.radians(90 - self.start_angle - i * angle_step)
124+
sector_points.append(Point(center.x + 2 * self.outer_radius * math.cos(angle), center.y + 2 * self.outer_radius * math.sin(angle)))
125+
sector_points.append(center)
126+
sector = Polygon(sector_points).buffer(0)
127+
128+
result = outer_ring.difference(inner_ring).intersection(sector)
129+
return Polygon(result) # type: ignore[arg-type]
130+
131+
@property
132+
def area(self) -> MM2:
133+
"""
134+
Calculate the area of the annular sector cross-section [mm²].
135+
136+
Returns
137+
-------
138+
MM2
139+
The area of the annular sector.
140+
"""
141+
area_outer_circle = math.pi * self.outer_radius**2
142+
area_inner_circle = math.pi * self.inner_radius**2
143+
area_ring = area_outer_circle - area_inner_circle
144+
return area_ring / 360 * (self.end_angle - self.start_angle)
145+
146+
@property
147+
def perimeter(self) -> MM:
148+
"""
149+
Calculate the perimeter of the annular sector cross-section [mm].
150+
151+
Returns
152+
-------
153+
MM
154+
The perimeter of the annular sector.
155+
"""
156+
angle_radians = math.radians(self.end_angle - self.start_angle)
157+
return angle_radians * (self.outer_radius + self.inner_radius) + 2 * self.thickness
158+
159+
@property
160+
def centroid(self) -> Point:
161+
"""
162+
Get the centroid of the annular sector cross-section.
163+
164+
Returns
165+
-------
166+
Point
167+
The centroid of the annular sector.
168+
"""
169+
halve_angle_radians = math.radians(self.end_angle - self.start_angle) / 2
170+
centroid_radius = (
171+
(2 * np.sin(halve_angle_radians) / 3 / halve_angle_radians)
172+
* (self.outer_radius**3 - self.inner_radius**3)
173+
/ (self.outer_radius**2 - self.inner_radius**2)
174+
)
175+
centroid_angle = math.radians((self.start_angle + self.end_angle) / 2)
176+
centroid_x = self.x + centroid_radius * math.cos(math.radians(90) - centroid_angle)
177+
centroid_y = self.y + centroid_radius * math.sin(math.radians(90) - centroid_angle)
178+
return Point(centroid_x, centroid_y)
179+
180+
@property
181+
def moment_of_inertia_about_y(self) -> MM4:
182+
"""
183+
Moments of inertia of the cross-section about the y-axis [mm⁴].
184+
185+
Returns
186+
-------
187+
MM4
188+
The moment of inertia about the y-axis.
189+
"""
190+
# based on https://engineering.stackexchange.com/a/60564
191+
# with y horizontal and z vertical
192+
theta = math.radians(self.end_angle - self.start_angle)
193+
term0 = self.outer_radius**4 - self.inner_radius**4
194+
term1 = (theta + math.sin(theta)) / 8
195+
term2 = (8 * math.sin(theta / 2) ** 2) / (9 * theta)
196+
term3 = (8 * math.sin(theta / 2) ** 2) / (9 * theta * (self.outer_radius + self.inner_radius) ** 2)
197+
term4 = self.inner_radius**4 * self.outer_radius**2 - self.outer_radius**4 * self.inner_radius**2
198+
199+
i_z_annulus = term0 * (term1 - term2) + term3 * term4
200+
i_y_annulus = term0 * (theta - math.sin(theta)) / 8
201+
202+
beta = np.pi / 2 - math.radians(self.end_angle + self.start_angle) / 2
203+
return (i_y_annulus + i_z_annulus) / 2 + (i_y_annulus - i_z_annulus) / 2 * math.cos(2 * beta)
204+
205+
@property
206+
def moment_of_inertia_about_z(self) -> MM4:
207+
"""
208+
Moments of inertia of the cross-section about the z-axis [mm⁴].
209+
210+
Returns
211+
-------
212+
MM4
213+
The moment of inertia about the z-axis.
214+
"""
215+
# based on https://engineering.stackexchange.com/a/60564
216+
# with y horizontal and z vertical
217+
theta = math.radians(self.end_angle - self.start_angle)
218+
term0 = self.outer_radius**4 - self.inner_radius**4
219+
term1 = (theta + math.sin(theta)) / 8
220+
term2 = (8 * math.sin(theta / 2) ** 2) / (9 * theta)
221+
term3 = (8 * math.sin(theta / 2) ** 2) / (9 * theta * (self.outer_radius + self.inner_radius) ** 2)
222+
term4 = self.inner_radius**4 * self.outer_radius**2 - self.outer_radius**4 * self.inner_radius**2
223+
224+
i_z_annulus = term0 * (term1 - term2) + term3 * term4
225+
i_y_annulus = term0 * (theta - math.sin(theta)) / 8
226+
227+
beta = np.pi / 2 - math.radians(self.end_angle + self.start_angle) / 2
228+
return (i_y_annulus + i_z_annulus) / 2 - (i_y_annulus - i_z_annulus) / 2 * math.cos(2 * beta)
229+
230+
@property
231+
def elastic_section_modulus_about_y_positive(self) -> MM3:
232+
"""
233+
Elastic section modulus about the y-axis on the positive z side [mm³].
234+
Note: No closed form equation was found, therefore this approximation is used.
235+
236+
Returns
237+
-------
238+
MM3
239+
The elastic section modulus about the y-axis.
240+
"""
241+
distance_to_top = max(y for _, y in self.polygon.exterior.coords) - self.centroid.y
242+
return self.moment_of_inertia_about_y / distance_to_top
243+
244+
@property
245+
def elastic_section_modulus_about_y_negative(self) -> MM3:
246+
"""
247+
Elastic section modulus about the y-axis on the negative z side [mm³].
248+
Note: No closed form equation was found, therefore this approximation is used.
249+
250+
Returns
251+
-------
252+
MM3
253+
The elastic section modulus about the y-axis.
254+
"""
255+
distance_to_bottom = self.centroid.y - min(y for _, y in self.polygon.exterior.coords)
256+
return self.moment_of_inertia_about_y / distance_to_bottom
257+
258+
@property
259+
def elastic_section_modulus_about_z_positive(self) -> MM3:
260+
"""
261+
Elastic section modulus about the z-axis on the positive y side [mm³].
262+
Note: No closed form equation was found, therefore this approximation is used.
263+
264+
Returns
265+
-------
266+
MM3
267+
The elastic section modulus about the z-axis.
268+
"""
269+
distance_to_right = max(x for x, _ in self.polygon.exterior.coords) - self.centroid.x
270+
return self.moment_of_inertia_about_z / distance_to_right
271+
272+
@property
273+
def elastic_section_modulus_about_z_negative(self) -> MM3:
274+
"""
275+
Elastic section modulus about the z-axis on the negative y side [mm³].
276+
Note: No closed form equation was found, therefore this approximation is used.
277+
278+
Returns
279+
-------
280+
MM3
281+
The elastic section modulus about the z-axis.
282+
"""
283+
distance_to_left = self.centroid.x - min(x for x, _ in self.polygon.exterior.coords)
284+
return self.moment_of_inertia_about_z / distance_to_left
285+
286+
@property
287+
def plastic_section_modulus_about_y(self) -> MM3 | None:
288+
"""
289+
Plastic section modulus about the y-axis [mm³].
290+
Note: No closed form equation was found, therefore this approximation is used.
291+
292+
Returns
293+
-------
294+
MM3
295+
The plastic section modulus about the y-axis.
296+
"""
297+
return None
298+
299+
@property
300+
def plastic_section_modulus_about_z(self) -> MM3 | None:
301+
"""
302+
Plastic section modulus about the z-axis [mm³].
303+
Note: No closed form equation was found, therefore this conservative approximation is used.
304+
305+
Returns
306+
-------
307+
MM3
308+
The plastic section modulus about the z-axis.
309+
"""
310+
return None
311+
312+
def geometry(
313+
self,
314+
mesh_size: MM | None = None,
315+
) -> Geometry:
316+
"""Return the geometry of the annular sector cross-section.
317+
318+
Parameters
319+
----------
320+
mesh_size : MM
321+
Maximum mesh element area to be used within
322+
the Geometry-object finite-element mesh. If not provided, a default value will be used.
323+
324+
Returns
325+
-------
326+
Geometry
327+
The Geometry object representing the annular sector.
328+
"""
329+
if mesh_size is None:
330+
minimum_mesh_size = 1.0
331+
mesh_length = max(self.thickness / 5, minimum_mesh_size)
332+
mesh_size = mesh_length**2
333+
334+
annular_sector = Geometry(geom=self.polygon)
335+
annular_sector.create_mesh(mesh_sizes=mesh_size)
336+
return annular_sector

tests/structural_sections/conftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44
from sectionproperties.post.post import SectionProperties
55

6+
from blueprints.structural_sections.cross_section_annular_sector import AnnularSectorCrossSection
67
from blueprints.structural_sections.cross_section_circle import CircularCrossSection
78
from blueprints.structural_sections.cross_section_hexagon import HexagonalCrossSection
89
from blueprints.structural_sections.cross_section_quarter_circular_spandrel import QuarterCircularSpandrelCrossSection
@@ -53,3 +54,31 @@ def qcs_cross_section() -> QuarterCircularSpandrelCrossSection:
5354
def hexagonal_cross_section() -> HexagonalCrossSection:
5455
"""Return a HexagonalCrossSection instance."""
5556
return HexagonalCrossSection(name="Hexagon", side_length=50.0, x=100.0, y=250.0)
57+
58+
59+
@pytest.fixture
60+
def annular_sector_cross_section() -> AnnularSectorCrossSection:
61+
"""Return an AnnularSectorCrossSection instance."""
62+
return AnnularSectorCrossSection(
63+
inner_radius=90.0,
64+
thickness=20.0,
65+
start_angle=0.0,
66+
end_angle=90.0,
67+
x=100.0,
68+
y=250.0,
69+
name="AnnularSector",
70+
)
71+
72+
73+
@pytest.fixture
74+
def annular_sector_cross_section_359_degrees() -> AnnularSectorCrossSection:
75+
"""Return an AnnularSectorCrossSection instance."""
76+
return AnnularSectorCrossSection(
77+
inner_radius=90.0,
78+
thickness=20.0,
79+
start_angle=90.0,
80+
end_angle=90.0 + 359.0,
81+
x=0.0,
82+
y=0.0,
83+
name="AnnularSector",
84+
)

0 commit comments

Comments
 (0)