Source code for api.app.calculators.analysis.section_properties

"""
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 SectionPropertiesInput(BaseModel): """Input schema for section properties calculation.""" section_type: str = Field( ..., description="Section type", pattern="^(rectangular|circular|i_section|channel|angle|t_section|custom)$", ) # Standard section parameters section_designation: Optional[str] = Field( None, description="Standard section designation (e.g., ISMB400, ISMC200)" ) # Custom section dimensions dimensions: Optional[Dict[str, float]] = Field( None, description="Custom section dimensions in mm" ) # Material properties material_grade: str = Field( "E250", description="Material grade (E250/E350/E410/E450 for steel)", pattern="^(E250|E350|E410|E450|M15|M20|M25|M30|M35|M40|M45|M50)$", ) # Analysis options mesh_size: Optional[float] = Field( 10.0, ge=0.1, le=50.0, description="Mesh size for finite element analysis in mm" ) include_warping: bool = Field(True, description="Include warping properties calculation") include_plastic: bool = Field(True, description="Include plastic section properties")
[docs] @field_validator("dimensions") @classmethod def validate_dimensions( cls, v: Optional[Dict[str, Any]], info: Any ) -> Optional[Dict[str, Any]]: """Validate dimensions based on section type.""" values = info.data section_type = values.get("section_type") section_designation = values.get("section_designation") # If standard section designation is provided, dimensions are optional if section_designation: return v # For custom sections, dimensions are required if not v: raise ValueError(f"Dimensions required for custom {section_type} section") required_dims = { "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"], } required = required_dims.get(section_type, []) missing = [dim for dim in required if dim not in v] if missing: raise ValueError(f"Missing required dimensions for {section_type}: {missing}") # Validate that all dimension values are positive for dim_name, dim_value in v.items(): if isinstance(dim_value, (int, float)) and dim_value <= 0: raise ValueError(f"Dimension '{dim_name}' must be positive, got {dim_value}") return v
[docs] @model_validator(mode="after") def validate_model(self) -> "SectionPropertiesInput": """Validate the complete model.""" # Check that either section_designation or dimensions are provided for custom sections if not self.section_designation and not self.dimensions: raise ValueError( f"Either section_designation or dimensions must be provided for {self.section_type} section" ) # If custom section (no designation), validate required dimensions if not self.section_designation and self.dimensions: required_dims = { "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"], } required = required_dims.get(self.section_type, []) missing = [dim for dim in required if dim not in self.dimensions] if missing: raise ValueError(f"Missing required dimensions for {self.section_type}: {missing}") return self
[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", }