"""
Section Properties Calculator for StructEngine v2
Calculate geometric properties of structural sections using sectionproperties library.
Supports standard steel sections, custom sections, and compound sections.
"""
import math
import time
from typing import Any, Dict, Optional, Type
import structlog
from pydantic import BaseModel, Field, field_validator, model_validator
try:
import sectionproperties.pre.library.primitive_sections as primitive_sections
import sectionproperties.pre.library.steel_sections as steel_sections
from sectionproperties.analysis.section import Section
from sectionproperties.pre.geometry import Geometry
from sectionproperties.pre.pre import Material
except ImportError as e:
raise ImportError(
f"sectionproperties library not found: {e}. Install with: pip install sectionproperties"
)
from ..base import BaseCalculator, CalculationError
logger = structlog.get_logger()
[docs]
class SectionPropertiesOutput(BaseModel):
"""Output schema for section properties results."""
inputs: SectionPropertiesInput
# Geometric properties
area: float = Field(..., description="Cross-sectional area in mm²")
perimeter: float = Field(..., description="Perimeter in mm")
# Centroidal properties
cx: float = Field(..., description="Centroid x-coordinate in mm")
cy: float = Field(..., description="Centroid y-coordinate in mm")
# Second moments of area
ixx: float = Field(..., description="Second moment of area about x-axis in mm⁴")
iyy: float = Field(..., description="Second moment of area about y-axis in mm⁴")
ixy: float = Field(..., description="Product of inertia in mm⁴")
# Section moduli
zxx_top: float = Field(..., description="Elastic section modulus about x-axis (top) in mm³")
zxx_bottom: float = Field(
..., description="Elastic section modulus about x-axis (bottom) in mm³"
)
zyy_left: float = Field(..., description="Elastic section modulus about y-axis (left) in mm³")
zyy_right: float = Field(..., description="Elastic section modulus about y-axis (right) in mm³")
# Radii of gyration
rx: float = Field(..., description="Radius of gyration about x-axis in mm")
ry: float = Field(..., description="Radius of gyration about y-axis in mm")
# Principal properties
i11: float = Field(..., description="Major principal second moment of area in mm⁴")
i22: float = Field(..., description="Minor principal second moment of area in mm⁴")
phi: float = Field(..., description="Principal axis angle in degrees")
# Warping properties (optional)
j: Optional[float] = Field(None, description="Torsion constant in mm⁴")
gamma: Optional[float] = Field(None, description="Warping constant in mm⁶")
# Plastic properties (optional)
sxx: Optional[float] = Field(None, description="Plastic section modulus about x-axis in mm³")
syy: Optional[float] = Field(None, description="Plastic section modulus about y-axis in mm³")
# Shear properties (optional)
a_sx: Optional[float] = Field(None, description="Shear area in x-direction in mm²")
a_sy: Optional[float] = Field(None, description="Shear area in y-direction in mm²")
# Material properties
material_type: str = Field(..., description="Material type (steel/concrete)")
elastic_modulus: float = Field(..., description="Elastic modulus in GPa")
# Calculation metadata
mesh_elements: int = Field(..., description="Number of finite elements in mesh")
calculation_time: float = Field(..., description="Calculation time in seconds")
status: str = Field(..., description="Calculation status")
[docs]
class SectionPropertiesCalculator(BaseCalculator):
"""Calculator for section properties using sectionproperties library."""
[docs]
def __init__(self) -> None:
super().__init__("section_properties", "2.0.0")
self.code_references = [
"IS 800:2007 - General Construction in Steel",
"IS 456:2000 - Plain and Reinforced Concrete",
"IS 875:1987 - Code of Practice for Design Loads",
]
@property
def input_schema(self) -> Type[BaseModel]:
"""
Return the Pydantic input schema for section properties calculation.
Returns:
Type[BaseModel]: SectionPropertiesInput schema class defining required
and optional parameters for section analysis
"""
return SectionPropertiesInput
@property
def output_schema(self) -> Type[BaseModel]:
"""
Return the Pydantic output schema for section properties results.
Returns:
Type[BaseModel]: SectionPropertiesOutput schema class containing all
calculated geometric and section properties
"""
return SectionPropertiesOutput
[docs]
def get_name(self) -> str:
return "Section Properties Calculator"
[docs]
def get_description(self) -> str:
return "Calculate geometric and section properties of structural members using finite element analysis"
def _create_material(self, material_grade: str) -> Material:
"""
Create sectionproperties Material object based on IS code grade.
Configures material properties including elastic modulus, Poisson's ratio,
density, and yield/compressive strength according to Indian Standards.
Args:
material_grade: Steel grade (E250/E350/E410/E450) per IS 800:2007
or concrete grade (M15-M50) per IS 456:2000
Returns:
Material: Configured material with properties:
- Steel: E=200 GPa, ν=0.3, ρ=7850 kg/m³
- Concrete: E=5000√fck MPa, ν=0.2, ρ=2500 kg/m³
Raises:
ValueError: If material grade is not recognized
Example:
>>> calc = SectionPropertiesCalculator()
>>> mat = calc._create_material("E250")
>>> mat.elastic_modulus
200000.0
References:
IS 800:2007 Table 1 - Mechanical properties of structural steel
IS 456:2000 Table 6 - Characteristic compressive strength of concrete
IS 456:2000 Clause 6.2.3.1 - Modulus of elasticity of concrete
"""
# Steel material properties (IS 800:2007)
steel_materials = {
"E250": {"E": 200000, "density": 7850e-9, "poisson": 0.3, "fy": 250},
"E350": {"E": 200000, "density": 7850e-9, "poisson": 0.3, "fy": 350},
"E410": {"E": 200000, "density": 7850e-9, "poisson": 0.3, "fy": 410},
"E450": {"E": 200000, "density": 7850e-9, "poisson": 0.3, "fy": 450},
}
# Concrete material properties (IS 456:2000)
concrete_materials = {
"M15": {"E": 22000, "density": 2500e-9, "poisson": 0.2, "fck": 15},
"M20": {"E": 25000, "density": 2500e-9, "poisson": 0.2, "fck": 20},
"M25": {"E": 27000, "density": 2500e-9, "poisson": 0.2, "fck": 25},
"M30": {"E": 29000, "density": 2500e-9, "poisson": 0.2, "fck": 30},
"M35": {"E": 31000, "density": 2500e-9, "poisson": 0.2, "fck": 35},
"M40": {"E": 32000, "density": 2500e-9, "poisson": 0.2, "fck": 40},
"M45": {"E": 34000, "density": 2500e-9, "poisson": 0.2, "fck": 45},
"M50": {"E": 35000, "density": 2500e-9, "poisson": 0.2, "fck": 50},
}
if material_grade in steel_materials:
props = steel_materials[material_grade]
return Material(
name=material_grade,
elastic_modulus=props["E"],
poissons_ratio=props["poisson"],
density=props["density"],
yield_strength=props["fy"],
color="lightblue",
)
elif material_grade in concrete_materials:
props = concrete_materials[material_grade]
return Material(
name=material_grade,
elastic_modulus=props["E"],
poissons_ratio=props["poisson"],
density=props["density"],
yield_strength=props["fck"],
color="lightgray",
)
else:
raise ValueError(f"Unknown material grade: {material_grade}")
def _create_geometry(self, inputs: SectionPropertiesInput) -> Geometry:
"""
Create section geometry from standard designation or custom dimensions.
Routes to appropriate geometry creation method based on whether a standard
section designation (e.g., ISMB400) or custom dimensions are provided.
Args:
inputs: Validated input parameters containing either:
- section_designation: Standard section (ISMB, ISMC)
- dimensions: Custom section dimensions in mm
Returns:
Geometry: sectionproperties Geometry object ready for FEA meshing
Raises:
CalculationError: If neither designation nor dimensions are provided
Note:
Standard sections use library steel_sections with IS code proportions.
Custom sections use primitive_sections with user-provided dimensions.
"""
material = self._create_material(inputs.material_grade)
# Handle standard section designations
if inputs.section_designation:
return self._create_standard_section(inputs.section_designation, material)
# Handle custom sections
# For custom sections, use provided dimensions
if inputs.dimensions:
return self._create_custom_section(inputs.section_type, inputs.dimensions, material)
else:
raise CalculationError("Dimensions are required for custom sections")
def _create_standard_section(self, designation: str, material: Material) -> Geometry:
"""
Create standard Indian steel section geometry.
Generates section geometry for standard Indian sections using simplified
proportions. Currently supports ISMB (I-beams) and ISMC (channels).
Args:
designation: Section designation following IS 808:1989 format:
- ISMB: "ISMB" + depth in mm (e.g., "ISMB400")
- ISMC: "ISMC" + depth in mm (e.g., "ISMC200")
material: Material properties from _create_material()
Returns:
Geometry: Standard section geometry with material properties
Raises:
ValueError: If designation format is invalid or section type unsupported
Note:
Uses simplified proportions for demonstration. For design calculations,
verify dimensions against IS 808:1989 or SP 6(1) steel tables.
Example:
>>> mat = calc._create_material("E250")
>>> geom = calc._create_standard_section("ISMB400", mat)
>>> # Creates I-section: depth=400mm, width=132mm (0.33*400)
TODO:
Add comprehensive database of standard sections from IS 808:1989
including actual dimensions for all ISMB, ISMC, ISJB, ISLB sections.
References:
IS 808:1989 - Dimensions for hot rolled steel beam, column, channel sections
SP 6(1):1964 - Handbook for structural engineers (steel sections)
"""
# This is a simplified implementation
# In practice, you would have a comprehensive database of standard sections
if designation.startswith("ISMB"):
# Example: ISMB400 (depth = 400mm)
depth = int(designation[4:])
# Standard ISMB proportions (simplified)
width = depth * 0.33
tw = 8.5 if depth >= 300 else 6.5
tf = 13.0 if depth >= 300 else 10.0
return steel_sections.i_section(
d=depth, b=width, t_f=tf, t_w=tw, r=10, n_r=8, material=material
)
elif designation.startswith("ISMC"):
# Example: ISMC200
depth = int(designation[4:])
width = depth * 0.375
tw = 6.5
tf = 10.5
return steel_sections.channel_section(
d=depth, b=width, t_f=tf, t_w=tw, r=10, n_r=8, material=material
)
else:
raise ValueError(f"Unknown section designation: {designation}")
def _create_custom_section(
self, section_type: str, dimensions: Dict[str, float], material: Material
) -> Geometry:
"""
Create custom section geometry from user-provided dimensions.
Generates section geometry for custom sections using sectionproperties
primitive and steel section libraries. All dimensions are in millimeters.
Args:
section_type: Section type identifier, one of:
- rectangular: Solid rectangular section
- circular: Solid circular section
- i_section: I-shaped section (doubly symmetric)
- channel: C-shaped channel section
- angle: L-shaped angle section
- t_section: T-shaped tee section
dimensions: Section dimensions in mm (validated by input schema):
- rectangular: width, height
- circular: diameter
- i_section: depth, width, flange_thickness, web_thickness
- channel: depth, width, flange_thickness, web_thickness
- angle: width, height, thickness
- t_section: width, depth, flange_thickness, web_thickness
material: Material properties from _create_material()
Returns:
Geometry: Custom section geometry ready for FEA meshing
Raises:
ValueError: If section type is unsupported
Example:
>>> mat = calc._create_material("E250")
>>> dims = {"depth": 400, "width": 200, "flange_thickness": 15, "web_thickness": 10}
>>> geom = calc._create_custom_section("i_section", dims, mat)
Note:
Root radius defaults to 10mm for welded sections, 5mm for angles.
Toe radius for angles defaults to 2.5mm (typical for rolled sections).
"""
if section_type == "rectangular":
return primitive_sections.rectangular_section(
d=dimensions["height"], b=dimensions["width"], material=material
)
elif section_type == "circular":
return primitive_sections.circular_section(
d=dimensions["diameter"], n=32, material=material
)
elif section_type == "i_section":
return steel_sections.i_section(
d=dimensions["depth"],
b=dimensions["width"],
t_f=dimensions["flange_thickness"],
t_w=dimensions["web_thickness"],
r=dimensions.get("root_radius", 10),
n_r=8,
material=material,
)
elif section_type == "channel":
return steel_sections.channel_section(
d=dimensions["depth"],
b=dimensions["width"],
t_f=dimensions["flange_thickness"],
t_w=dimensions["web_thickness"],
r=dimensions.get("root_radius", 10),
n_r=8,
material=material,
)
elif section_type == "angle":
return steel_sections.angle_section(
d=dimensions["height"],
b=dimensions["width"],
t=dimensions["thickness"],
r_r=dimensions.get("root_radius", 5),
r_t=dimensions.get("toe_radius", 2.5),
n_r=8,
material=material,
)
elif section_type == "t_section":
return steel_sections.tee_section(
d=dimensions["depth"],
b=dimensions["width"],
t_f=dimensions["flange_thickness"],
t_w=dimensions["web_thickness"],
r=dimensions.get("root_radius", 10),
n_r=8,
material=material,
)
else:
raise ValueError(f"Unsupported section type: {section_type}")
[docs]
def calculate(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
"""Calculate section properties using sectionproperties library."""
start_time = time.time()
try:
# Validate and parse inputs
validated_inputs = SectionPropertiesInput(**inputs)
logger.info(
"Section properties calculation started",
section_type=validated_inputs.section_type,
material=validated_inputs.material_grade,
)
# Create geometry
geometry = self._create_geometry(validated_inputs)
# Create mesh
mesh_size = validated_inputs.mesh_size if validated_inputs.mesh_size else 10.0
geometry.create_mesh(mesh_sizes=[mesh_size])
mesh_elements = (
len(geometry.mesh["triangles"])
if geometry.mesh and "triangles" in geometry.mesh
else 0
)
# Create section analysis object
section = Section(geometry)
# Calculate geometric properties
section.calculate_geometric_properties()
# Calculate warping properties if requested
if validated_inputs.include_warping:
try:
section.calculate_warping_properties()
except Exception as e:
logger.warning("Warping properties calculation failed", error=str(e))
# Calculate plastic properties if requested
if validated_inputs.include_plastic:
try:
section.calculate_plastic_properties()
except Exception as e:
logger.warning("Plastic properties calculation failed", error=str(e))
# Extract results
results = self._extract_results(section, validated_inputs, mesh_elements, start_time)
logger.info(
"Section properties calculation completed successfully",
calculation_time=results["calculation_time"],
)
return results
except Exception as e:
logger.error("Section properties calculation failed", error=str(e))
raise CalculationError(f"Section properties calculation failed: {e!s}")
def _extract_results(
self,
section: Section,
inputs: SectionPropertiesInput,
mesh_elements: int,
start_time: float,
) -> Dict[str, Any]:
"""
Extract and format section analysis results from FEA solution.
Processes sectionproperties Section object to extract all geometric and
section properties, handling both elastic (material-based) and geometric
(pure geometry) property calculations.
Args:
section: Analyzed sectionproperties Section object with computed properties
inputs: Original input parameters for reference
mesh_elements: Number of triangular elements in FEA mesh
start_time: Calculation start timestamp for duration measurement
Returns:
Dict containing formatted results with units:
Geometric properties:
- area (mm²): Cross-sectional area
- perimeter (mm): Section perimeter
- cx, cy (mm): Centroid coordinates
Second moments of area (mm⁴):
- ixx, iyy: About centroidal axes
- ixy: Product of inertia
Section moduli (mm³):
- zxx_top, zxx_bottom: Elastic moduli about x-axis
- zyy_left, zyy_right: Elastic moduli about y-axis
- sxx, syy: Plastic section moduli (if calculated)
Radii of gyration (mm):
- rx, ry: About centroidal axes
Principal properties:
- i11, i22 (mm⁴): Principal second moments
- phi (degrees): Principal axis angle
Warping properties (if calculated):
- j (mm⁴): Torsion constant
- gamma (mm⁶): Warping constant
Shear areas (mm²):
- a_sx, a_sy: Effective shear areas (if available)
Note:
Results are rounded per engineering standards:
- Areas: 2 decimal places (mm²)
- Moments of inertia: integer (mm⁴)
- Section moduli: integer (mm³)
- Radii: 2 decimal places (mm)
- Angles: 2 decimal places (degrees)
References:
IS 800:2007 Annex C - Calculation of section properties
IS 800:2007 Clause 8.1 - Section classification and properties
"""
# Get material properties
material = inputs.material_grade
if material.startswith("E"):
material_type = "steel"
elastic_modulus = 200.0 # GPa
else:
material_type = "concrete"
# Calculate elastic modulus using IS 456: E = 5000 * sqrt(fck) in MPa, convert to GPa
fck_values = {
"M15": 15,
"M20": 20,
"M25": 25,
"M30": 30,
"M35": 35,
"M40": 40,
"M45": 45,
"M50": 50,
}
fck = fck_values.get(material, 25) # Default to M25
elastic_modulus = (5000 * (fck**0.5)) / 1000 # Convert MPa to GPa
# Geometric properties
perimeter = section.get_perimeter()
cx, cy = section.get_c()
# Second moments of area
try:
# For sections with material properties, use elastic properties
if material_type in ["steel", "concrete"]:
eixx, eiyy, eixy = section.get_eic()
ea = section.get_ea()
# Convert elastic values back to geometric values
area = ea / (elastic_modulus * 1000) # Convert GPa to MPa
ixx = eixx / (elastic_modulus * 1000) # Convert EI back to I
iyy = eiyy / (elastic_modulus * 1000) # Convert EI back to I
ixy = eixy / (elastic_modulus * 1000) # Convert EI back to I
else:
# For pure geometric analysis
ixx, iyy, ixy = section.get_ic()
area = section.get_area()
except Exception:
# Fallback to geometric properties
try:
ixx, iyy, ixy = section.get_ic()
area = section.get_area()
except Exception as e:
raise CalculationError(f"Failed to extract section properties: {e!s}")
# Section moduli
try:
if material_type in ["steel", "concrete"]:
ezxx_plus, ezxx_minus, ezyy_plus, ezyy_minus = section.get_ez()
# Convert elastic section moduli back to geometric values
zxx_plus = ezxx_plus / (elastic_modulus * 1000)
zxx_minus = ezxx_minus / (elastic_modulus * 1000)
zyy_plus = ezyy_plus / (elastic_modulus * 1000)
zyy_minus = ezyy_minus / (elastic_modulus * 1000)
else:
zxx_plus, zxx_minus, zyy_plus, zyy_minus = section.get_z()
except Exception:
try:
zxx_plus, zxx_minus, zyy_plus, zyy_minus = section.get_z()
except Exception:
zxx_plus = zxx_minus = zyy_plus = zyy_minus = 0.0
# Radii of gyration - these are always geometric properties
try:
rx, ry = section.get_rc()
except Exception:
rx = ry = 0.0
# Principal properties
try:
if material_type in ["steel", "concrete"]:
ei11, ei22 = section.get_eip()
# Convert elastic principal moments back to geometric values
i11 = ei11 / (elastic_modulus * 1000)
i22 = ei22 / (elastic_modulus * 1000)
else:
i11, i22 = section.get_ip()
except Exception:
try:
i11, i22 = section.get_ip()
except Exception:
i11 = i22 = 0.0
try:
phi = math.degrees(section.get_phi()) # Convert to degrees
except Exception:
phi = 0.0
# Optional properties
j = None
gamma = None
sxx = None
syy = None
a_sx = None
a_sy = None
# Warping properties
if inputs.include_warping:
try:
if material_type in ["steel", "concrete"]:
j = section.get_ej() / (elastic_modulus * 1000) # Convert EJ back to J
gamma = section.get_gamma() # Gamma is always geometric
else:
j = section.get_j()
gamma = section.get_gamma()
except Exception as e: # nosec B110: library call may not be available for all sections
logger.warning("Warping properties not available", error=str(e))
# Plastic properties
if inputs.include_plastic:
try:
# For plastic properties, try to get values
# These might not have elastic equivalents
sxx, syy = section.get_s()
except Exception as e: # nosec B110: optional property; log and continue
logger.warning("Plastic properties not available", error=str(e))
# Shear areas (if available)
try:
a_sx, a_sy = section.get_as()
except Exception as e: # nosec B110: optional property; log and continue
logger.warning("Shear area properties not available", error=str(e))
calculation_time = time.time() - start_time
return {
"inputs": inputs.model_dump(),
"area": round(area, 2),
"perimeter": round(perimeter, 2),
"cx": round(cx, 3),
"cy": round(cy, 3),
"ixx": round(ixx, 0),
"iyy": round(iyy, 0),
"ixy": round(ixy, 0),
"zxx_top": round(zxx_plus, 0),
"zxx_bottom": round(abs(zxx_minus), 0),
"zyy_left": round(abs(zyy_minus), 0),
"zyy_right": round(zyy_plus, 0),
"rx": round(rx, 2),
"ry": round(ry, 2),
"i11": round(i11, 0),
"i22": round(i22, 0),
"phi": round(phi, 2),
"j": round(j, 0) if j is not None else None,
"gamma": round(gamma, 0) if gamma is not None else None,
"sxx": round(sxx, 0) if sxx is not None else None,
"syy": round(syy, 0) if syy is not None else None,
"a_sx": round(a_sx, 2) if a_sx is not None else None,
"a_sy": round(a_sy, 2) if a_sy is not None else None,
"material_type": material_type,
"elastic_modulus": elastic_modulus,
"mesh_elements": mesh_elements,
"calculation_time": round(calculation_time, 3),
"status": "SUCCESS",
}