"""
Purlin Design Calculator
Cold-formed steel purlin design as per IS 801:1975.
Performs comprehensive design including bending, shear, web crippling, deflection checks.
"""
import math
from typing import Any, Dict, List, Literal, Optional, Type
import structlog
from pydantic import BaseModel, Field, field_validator, model_validator
from ..base import BaseCalculator, CalculationError
# Import profile system
from .purlin_profiles import ALL_PROFILES, get_profile_properties
logger = structlog.get_logger(__name__)
# IS 801:1975 Constants
DEGREES_TO_RADIANS = math.pi / 180
KGF_TO_N = 9.81
KGF_CM2_TO_N_MM2 = 10.197 # Conversion factor from kgf/cm² to N/mm²
MIN_STIFFENER_INERTIA = 9.2
DEFAULT_CB = 1 # Default value for Cb in bending calculations
[docs]
class SectionProperties(BaseModel):
"""Section properties for cold-formed steel purlin."""
# Profile selection (alternative to manual input)
profile_name: Optional[str] = Field(
default=None, description="Standard profile name (e.g., 'Z20015')"
)
profile_type: Optional[Literal["Z45", "Z90", "C"]] = Field(
default=None, description="Profile type"
)
# Manual section properties (used if profile not selected)
thickness: Optional[float] = Field(default=None, gt=0, description="Section thickness in mm")
depth: Optional[float] = Field(default=None, gt=0, description="Section depth in mm")
flange_width_tp: Optional[float] = Field(
default=None, gt=0, description="Top flange width in mm"
)
flange_width_bt: Optional[float] = Field(
default=None, gt=0, description="Bottom flange width in mm"
)
lip_depth: Optional[float] = Field(default=None, ge=0, description="Lip depth in mm")
lip_angle: Optional[float] = Field(
default=None, ge=0, le=90, description="Lip angle in degrees"
)
Ixx: Optional[float] = Field(
default=None, gt=0, description="Moment of inertia about X-X axis in mm⁴"
)
Iyy: Optional[float] = Field(
default=None, gt=0, description="Moment of inertia about Y-Y axis in mm⁴"
)
Zxx_tp: Optional[float] = Field(
default=None, gt=0, description="Section modulus top fiber X-X axis in mm³"
)
Zxx_bt: Optional[float] = Field(
default=None, gt=0, description="Section modulus bottom fiber X-X axis in mm³"
)
Zyy: Optional[float] = Field(
default=None, gt=0, description="Section modulus about Y-Y axis in mm³"
)
area: Optional[float] = Field(default=None, gt=0, description="Cross-sectional area in mm²")
Rad: Optional[float] = Field(default=2.0, gt=0, description="Corner radius in mm")
# Computed properties (populated from profile or manual input)
_computed_properties: Optional[Dict[str, float]] = None
[docs]
def get_properties(self) -> Dict[str, float]:
"""Get section properties, either from profile lookup or manual input."""
if self._computed_properties is not None:
return self._computed_properties
# If profile specified, use profile lookup
if self.profile_name and self.profile_type:
profile_props = get_profile_properties(self.profile_name, self.profile_type)
if not profile_props:
raise ValueError(
f"Profile {self.profile_name} not found in {self.profile_type} profiles"
)
# All profile properties are now in mm units, no conversion needed
self._computed_properties = {
"thickness": (
float(profile_props["thickness"])
if profile_props["thickness"] is not None
else 0.0
),
"depth": (
float(profile_props["depth"]) if profile_props["depth"] is not None else 0.0
),
"flange_width_tp": (
float(profile_props["flange_width_tp"])
if profile_props["flange_width_tp"] is not None
else 0.0
),
"flange_width_bt": (
float(profile_props["flange_width_bt"])
if profile_props["flange_width_bt"] is not None
else 0.0
),
"lip_depth": (
float(profile_props["lip_depth"])
if profile_props["lip_depth"] is not None
else 0.0
),
"lip_angle": (
float(profile_props["lip_angle"])
if profile_props["lip_angle"] is not None
else 90.0
),
"Rad": float(profile_props["Rad"]) if profile_props["Rad"] is not None else 2.0,
"mass": (
float(profile_props.get("mass", 0.0))
if profile_props.get("mass", 0.0) is not None
else 0.0
),
"area": float(profile_props["area"]) if profile_props["area"] is not None else 0.0,
"Ixx": float(profile_props["Ixx"]) if profile_props["Ixx"] is not None else 0.0,
"Iyy": float(profile_props["Iyy"]) if profile_props["Iyy"] is not None else 0.0,
"Zxx_tp": (
float(profile_props["Zxx_tp"]) if profile_props["Zxx_tp"] is not None else 0.0
),
"Zxx_bt": (
float(profile_props["Zxx_bt"]) if profile_props["Zxx_bt"] is not None else 0.0
),
"Zyy": float(profile_props["Zyy"]) if profile_props["Zyy"] is not None else 0.0,
"Rx": (
float(profile_props.get("Rx", 0.0))
if profile_props.get("Rx", 0.0) is not None
else 0.0
),
"Ry": (
float(profile_props.get("Ry", 0.0))
if profile_props.get("Ry", 0.0) is not None
else 0.0
),
"Yb": (
float(profile_props.get("Yb", 0.0))
if profile_props.get("Yb", 0.0) is not None
else 0.0
),
}
return self._computed_properties
# If manual properties specified, use them
elif all(
[
self.thickness is not None,
self.depth is not None,
self.flange_width_tp is not None,
self.flange_width_bt is not None,
self.Ixx is not None,
self.Iyy is not None,
self.Zxx_tp is not None,
self.Zxx_bt is not None,
self.Zyy is not None,
self.area is not None,
]
):
self._computed_properties = {
"thickness": float(self.thickness) if self.thickness is not None else 0.0,
"depth": float(self.depth) if self.depth is not None else 0.0,
"flange_width_tp": (
float(self.flange_width_tp) if self.flange_width_tp is not None else 0.0
),
"flange_width_bt": (
float(self.flange_width_bt) if self.flange_width_bt is not None else 0.0
),
"lip_depth": float(self.lip_depth) if self.lip_depth is not None else 0.0,
"lip_angle": float(self.lip_angle) if self.lip_angle is not None else 90.0,
"Rad": float(self.Rad) if self.Rad is not None else 2.0,
"area": float(self.area) if self.area is not None else 0.0,
"Ixx": float(self.Ixx) if self.Ixx is not None else 0.0,
"Iyy": float(self.Iyy) if self.Iyy is not None else 0.0,
"Zxx_tp": float(self.Zxx_tp) if self.Zxx_tp is not None else 0.0,
"Zxx_bt": float(self.Zxx_bt) if self.Zxx_bt is not None else 0.0,
"Zyy": float(self.Zyy) if self.Zyy is not None else 0.0,
}
return self._computed_properties
else:
# Always raise error, never return None
raise ValueError(
"Either specify standard profile (profile_name + profile_type) or all required manual properties"
)
[docs]
@model_validator(mode="after")
def validate_profile_selection(self) -> "SectionProperties":
"""Validate profile selection after all fields are set."""
if self.profile_name:
if not self.profile_type:
raise ValueError("profile_type must be specified when profile_name is provided")
# Check if profile exists
profile_props = get_profile_properties(self.profile_name, self.profile_type)
if not profile_props:
raise ValueError(
f"Profile {self.profile_name} not found in {self.profile_type} profiles"
)
return self
[docs]
class MaterialProperties(BaseModel):
"""Material properties for steel."""
Fy: float = Field(default=350, gt=0, description="Yield strength in MPa")
E: float = Field(default=200000, gt=0, description="Elastic modulus in MPa")
[docs]
@field_validator("Fy")
@classmethod
def validate_yield_strength(cls, v: float) -> float:
"""Validate yield strength range for cold-formed steel."""
if not (200 <= v <= 500):
raise ValueError("Yield strength should be between 200-500 MPa for cold-formed steel")
return v
[docs]
class LoadingData(BaseModel):
"""Loading data for purlin design."""
roof: float = Field(..., ge=0, description="Roof dead load in kN/m²")
purlin: float = Field(..., ge=0, description="Purlin self-weight in kN/m")
live: float = Field(..., ge=0, description="Live load in kN/m²")
wind: float = Field(..., description="Wind load in kN/m² (can be negative)")
[docs]
@field_validator("wind")
@classmethod
def validate_wind_load(cls, v: float) -> float:
"""Validate wind load range."""
if not (-10 <= v <= 10):
raise ValueError("Wind load should be between -10 and 10 kN/m²")
return v
[docs]
class LoadResults(BaseModel):
"""Load calculation results."""
DL: float = Field(..., description="Total dead load in kN/m")
LL: float = Field(..., description="Total live load in kN/m")
WL: float = Field(..., description="Total wind load in kN/m")
DL_major: float = Field(..., description="Dead load major axis component in kN/m")
DL_minor: float = Field(..., description="Dead load minor axis component in kN/m")
LL_major: float = Field(..., description="Live load major axis component in kN/m")
LL_minor: float = Field(..., description="Live load minor axis component in kN/m")
WL_major: float = Field(..., description="Wind load major axis component in kN/m")
WL_minor: float = Field(..., description="Wind load minor axis component in kN/m")
[docs]
class MomentShearResults(BaseModel):
"""Moment and shear calculation results."""
M_DL_LL_major: float = Field(..., description="DL+LL major axis moment in kN-m")
M_DL_LL_minor: float = Field(..., description="DL+LL minor axis moment in kN-m")
M_DL_WL_major: float = Field(..., description="DL+WL major axis moment in kN-m")
M_DL_WL_minor: float = Field(..., description="DL+WL minor axis moment in kN-m")
V_DL_LL: float = Field(..., description="DL+LL shear force in kN")
V_DL_WL: float = Field(..., description="DL+WL shear force in kN")
max_moment: float = Field(..., description="Maximum moment in kN-m")
max_shear: float = Field(..., description="Maximum shear in kN")
[docs]
class StiffenerCheck(BaseModel):
"""Stiffener adequacy check results."""
stiffener_ok: bool = Field(..., description="Stiffener adequacy status")
I_actual: float = Field(..., description="Actual stiffener moment of inertia in mm⁴")
I_min: float = Field(..., description="Required minimum moment of inertia in mm⁴")
d_min: float = Field(..., description="Minimum required lip depth in mm")
lip_depth_ok: bool = Field(..., description="Lip depth adequacy status")
web_depth_ok: bool = Field(..., description="Web depth-to-thickness ratio check")
web_stiffened: bool = Field(..., description="Whether web is stiffened")
is_code_reference: str = Field(
default="IS 801:1975, Cl. 5.2.2",
description="IS code reference for stiffener requirements",
)
[docs]
class BendingCheck(BaseModel):
"""Bending stress check results."""
bending_stress_major: float = Field(..., description="Major axis bending stress in N/mm²")
bending_stress_minor: float = Field(..., description="Minor axis bending stress in N/mm²")
allowable_bending_major: float = Field(
..., description="Allowable major axis bending stress in N/mm²"
)
allowable_bending_minor: float = Field(
..., description="Allowable minor axis bending stress in N/mm²"
)
combined_bending_ratio: float = Field(..., description="Combined bending stress ratio")
bending_safe: bool = Field(..., description="Bending safety status")
utilization_ratio: float = Field(..., description="Bending utilization ratio")
spans: Dict[str, float] = Field(..., description="Effective spans used")
is_code_reference: str = Field(
default="IS 801:1975, Cl. 6.3",
description="IS code reference for laterally unbraced beams",
)
[docs]
class WebCripplingCheck(BaseModel):
"""Web crippling check results."""
Pmax: float = Field(..., description="Web crippling capacity in kN")
Pmax_end: float = Field(..., description="End reaction capacity in kN")
Pmax_interior: float = Field(..., description="Interior support capacity in kN")
controlling_case: str = Field(..., description="Controlling case")
shear_force: float = Field(..., description="Applied shear force in kN")
web_crippling_ok: bool = Field(..., description="Web crippling safety status")
utilization_ratio: float = Field(..., description="Web crippling utilization ratio")
is_code_reference: str = Field(
default="IS 801:1975, Cl. 6.5",
description="IS code reference for web crippling",
)
[docs]
class DeflectionCheck(BaseModel):
"""Deflection check results."""
actual_deflection: float = Field(..., description="Actual deflection in mm")
permissible_deflection: float = Field(..., description="Permissible deflection in mm")
deflection_safe: bool = Field(..., description="Deflection safety status")
deflection_ratio: str = Field(..., description="Deflection ratio (span/deflection)")
utilization_ratio: float = Field(..., description="Deflection utilization ratio")
is_code_reference: str = Field(
default="IS 801:1975, Cl. 8.2",
description="IS code reference for deflection limits",
)
[docs]
class PurlinDesignOutput(BaseModel):
"""Output schema for purlin design results."""
# Input summary
inputs: PurlinDesignInput = Field(..., description="Input parameters used")
# Load calculations
loads: LoadResults = Field(..., description="Load calculation results")
# Moment and shear results
moments_shears: MomentShearResults = Field(..., description="Moment and shear results")
# Structural checks
stiffener_check: StiffenerCheck = Field(..., description="Stiffener adequacy check")
bending_check: BendingCheck = Field(..., description="Bending stress check")
web_crippling_check: WebCripplingCheck = Field(..., description="Web crippling check")
deflection_check_major: DeflectionCheck = Field(..., description="Major axis deflection check")
deflection_check_minor: DeflectionCheck = Field(..., description="Minor axis deflection check")
# Overall results
design_safe: bool = Field(..., description="Overall design safety status")
critical_check: str = Field(..., description="Most critical check")
max_utilization_ratio: float = Field(..., description="Maximum utilization ratio")
design_recommendations: list[str] = Field(..., description="Design recommendations")
# IS code compliance
code_compliance: Dict[str, Any] = Field(..., description="IS 801:1975 code compliance summary")
[docs]
class PurlinDesignCalculator(BaseCalculator):
"""
Cold-formed steel purlin design calculator per IS 801:1975.
Performs comprehensive analysis including:
- Load resolution and combinations
- Bending stress analysis with lateral buckling
- Web crippling checks
- Deflection analysis
- Stiffener adequacy verification
"""
name = "Purlin Design"
[docs]
def __init__(self) -> None:
super().__init__("purlin_design", "1.0.0")
@property
def input_schema(self) -> Type[BaseModel]:
"""Return input validation schema class."""
return PurlinDesignInput
@property
def output_schema(self) -> Type[BaseModel]:
"""Return output formatting schema class."""
return PurlinDesignOutput
[docs]
def get_name(self) -> str:
"""Return calculator name."""
return "Purlin Design"
[docs]
def get_description(self) -> str:
"""Return calculator description."""
return "Cold-formed steel purlin design as per IS 801:1975"
[docs]
def calculate(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
"""
Perform purlin design calculation.
Args:
inputs: Validated input parameters
Returns:
Dictionary containing calculation results
"""
try:
logger.info("=" * 80)
logger.info("[PURLIN] Starting purlin design calculation")
logger.info(f"[PURLIN] Input keys: {list(inputs.keys())}")
# Extract input parameters
span = inputs["span"]
spacing = inputs["spacing"]
sag_rods = inputs["sag_rods"]
inclination = inputs["inclination"]
section = inputs["section"]
# Convert SectionProperties model to dict for calculations
if hasattr(section, "get_properties"):
section = section.get_properties()
elif isinstance(section, dict) and "profile_type" in section:
# Handle direct dict calls with profile selection
section = get_profile_properties(section["profile_name"], section["profile_type"])
# Validate section is a valid dictionary
if not isinstance(section, dict) or section is None:
raise CalculationError(
"Invalid section properties - section must be a valid dictionary"
)
material = inputs["material"]
loads = inputs["loads"]
logger.info(
f"[PURLIN] Extracted params - span: {span}m, spacing: {spacing}m, sag_rods: {sag_rods}, inclination: {inclination}°"
)
logger.info(f"[PURLIN] Section type: {type(section)}, Material: {material}")
# Calculate loads and forces
logger.info("[PURLIN] Calculating loads...")
load_results = self._calculate_loads(loads, spacing, inclination)
# Calculate moments and shears
logger.info("[PURLIN] Calculating moments and shears...")
moment_shear_results = self._calculate_moments_shears(load_results, span, sag_rods)
logger.info(
f"[PURLIN] Max moment: {moment_shear_results.get('max_moment')} kN-m, Max shear: {moment_shear_results.get('max_shear')} kN"
)
# Perform structural checks with effective width considerations
logger.info("[PURLIN] Performing structural checks...")
effective_width_results = self._check_flat_width_ratios(section, material)
stiffener_check = self._check_stiffeners(section, material, effective_width_results)
bending_check = self._check_bending(
section, material, moment_shear_results, span, sag_rods
)
web_crippling_check = self._check_web_crippling(section, material, moment_shear_results)
deflection_major = self._check_deflection_major(section, material, load_results, span)
deflection_minor = self._check_deflection_minor(
section, material, load_results, span, sag_rods
)
# Determine overall safety and recommendations
checks = [
stiffener_check,
bending_check,
web_crippling_check,
deflection_major,
deflection_minor,
]
design_safe = all(
[
stiffener_check["stiffener_ok"],
bending_check["bending_safe"],
web_crippling_check["web_crippling_ok"],
deflection_major["deflection_safe"],
deflection_minor["deflection_safe"],
]
)
# Find critical check and max utilization
utilization_ratios = {
"stiffener": 1.0 if not stiffener_check["stiffener_ok"] else 0.9,
"bending": (
min(bending_check["utilization_ratio"], 999.9)
if not math.isinf(bending_check["utilization_ratio"])
else 999.9
),
"web_crippling": (
min(web_crippling_check["utilization_ratio"], 999.9)
if not math.isinf(web_crippling_check["utilization_ratio"])
else 999.9
),
"deflection_major": (
min(deflection_major["utilization_ratio"], 999.9)
if not math.isinf(deflection_major["utilization_ratio"])
else 999.9
),
"deflection_minor": (
min(deflection_minor["utilization_ratio"], 999.9)
if not math.isinf(deflection_minor["utilization_ratio"])
else 999.9
),
}
max_utilization = max(utilization_ratios.values())
critical_check = max(utilization_ratios.items(), key=lambda x: x[1])[0]
# Generate recommendations
recommendations = self._generate_recommendations(
design_safe, critical_check, max_utilization, checks
)
# Code compliance summary
code_compliance = self._generate_code_compliance(checks)
# Prepare outputs in standard format (like slab design and crack width)
logger.info(
f"[PURLIN] Design safe: {design_safe}, Critical check: {critical_check}, Max utilization: {max_utilization}"
)
outputs = {
"loads": load_results,
"moments_shears": moment_shear_results,
"stiffener_check": stiffener_check,
"bending_check": bending_check,
"web_crippling_check": web_crippling_check,
"deflection_check_major": deflection_major,
"deflection_check_minor": deflection_minor,
"design_safe": design_safe,
"critical_check": critical_check.replace("_", " ").title(),
"max_utilization_ratio": round(max_utilization, 3),
"design_recommendations": recommendations,
"code_compliance": code_compliance,
}
result = {
"calculator_type": "purlin_design",
"inputs": inputs,
"outputs": outputs,
# Also include individual results for backward compatibility
**outputs,
}
logger.info("[PURLIN] Calculation completed successfully")
logger.info(f"[PURLIN] Result keys: {list(result.keys())}")
logger.info("=" * 80)
return result
except Exception as e:
logger.error(f"[PURLIN] Calculation failed with error: {e!s}")
logger.exception(e)
self.logger.error("Purlin design calculation failed", error=str(e))
raise CalculationError(f"Calculation failed: {e!s}")
def _calculate_loads(
self, loads: Dict[str, Any], spacing: float, inclination: float
) -> Dict[str, Any]:
"""Calculate total loads and resolve into components."""
# Total loads per meter
DL = (loads["roof"] * spacing) + loads["purlin"]
LL = loads["live"] * spacing
WL = loads["wind"] * spacing
# Resolve into major/minor axis components
theta_rad = inclination * DEGREES_TO_RADIANS
cos_theta = math.cos(theta_rad)
sin_theta = math.sin(theta_rad)
return {
"DL": DL,
"LL": LL,
"WL": WL,
"DL_major": DL * cos_theta,
"DL_minor": DL * sin_theta,
"LL_major": LL * cos_theta,
"LL_minor": LL * sin_theta,
"WL_major": WL * cos_theta,
"WL_minor": WL * sin_theta,
}
def _calculate_moments_shears(
self, loads: Dict[str, Any], span: float, sag_rods: int
) -> Dict[str, Any]:
"""Calculate bending moments and shear forces."""
# Effective spans
Lx = span # Major axis span
Ly = span / (sag_rods + 1) if sag_rods > 0 else span # Minor axis span
# Moments for load combinations (simply supported beam)
M_DL_LL_major = ((loads["DL_major"] + loads["LL_major"]) * Lx**2) / 8
M_DL_LL_minor = ((loads["DL_minor"] + loads["LL_minor"]) * Ly**2) / 8
M_DL_WL_major = ((loads["DL_major"] + loads["WL_major"]) * Lx**2) / 8
M_DL_WL_minor = ((loads["DL_minor"] + loads["WL_minor"]) * Ly**2) / 8
# Shear forces (major axis controls)
V_DL_LL = ((loads["DL_major"] + loads["LL_major"]) * Lx) / 2
V_DL_WL = ((loads["DL_major"] + loads["WL_major"]) * Lx) / 2
# Maximum values
max_moment = max(abs(M_DL_LL_major), abs(M_DL_WL_major))
max_shear = max(abs(V_DL_LL), abs(V_DL_WL))
return {
"M_DL_LL_major": M_DL_LL_major,
"M_DL_LL_minor": M_DL_LL_minor,
"M_DL_WL_major": M_DL_WL_major,
"M_DL_WL_minor": M_DL_WL_minor,
"V_DL_LL": V_DL_LL,
"V_DL_WL": V_DL_WL,
"max_moment": max_moment,
"max_shear": max_shear,
}
def _check_stiffeners(
self,
section: Dict[str, Any],
material: Dict[str, Any],
effective_width_results: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Check stiffener adequacy per IS 801:1975."""
t = section["thickness"]
lip_depth = section.get("lip_depth", 0)
lip_angle = section.get("lip_angle", 90)
# Calculate actual moment of inertia of lip stiffener
if lip_depth > 0 and t > 0:
lip_angle_rad = lip_angle * DEGREES_TO_RADIANS
I_actual = (t * lip_depth**3 * math.sin(lip_angle_rad) ** 2) / 12
else:
I_actual = 0.0
# Calculate minimum required moment of inertia
w = (
max(section["flange_width_tp"], section["flange_width_bt"])
- 2 * t
- 2 * section.get("Rad", 0)
)
Fy_kgf = material["Fy"] * 100 # Convert MPa to kgf/cm² (100 = CM2_TO_MM2)
if t > 0 and w > 0 and Fy_kgf > 0:
w_t_ratio = w / t
term = w_t_ratio**2 - 281200 / Fy_kgf
if term > 0:
I_min = 1.83 * (t**4) * math.sqrt(term)
else:
I_min = MIN_STIFFENER_INERTIA * (t**4)
else:
I_min = MIN_STIFFENER_INERTIA * (t**4)
I_min = max(I_min, MIN_STIFFENER_INERTIA * (t**4))
# Check minimum lip depth
if t > 0 and w > 0 and Fy_kgf > 0:
w_t_ratio = w / t
term = w_t_ratio**2 - 281200 / Fy_kgf
if term > 0:
d_min = max(2.8 * t * (term**0.167), 4.8 * t)
else:
d_min = 4.8 * t
else:
d_min = 4.8 * t
# Check web depth-to-thickness ratio
h = section["depth"] - 2 * t
h_t_ratio = h / t if t > 0 else 0
is_stiffened = lip_depth > 0
web_depth_ok = h_t_ratio <= (200 if is_stiffened else 150)
return {
"stiffener_ok": I_actual >= I_min,
"I_actual": round(I_actual, 2),
"I_min": round(I_min, 2),
"d_min": round(d_min, 2),
"lip_depth_ok": lip_depth >= d_min,
"web_depth_ok": web_depth_ok,
"web_stiffened": is_stiffened,
"is_code_reference": "IS 801:1975, Cl. 5.2.2 - Stiffener Requirements",
}
def _check_bending(
self,
section: Dict[str, Any],
material: Dict[str, Any],
moments: Dict[str, Any],
span: float,
sag_rods: int,
) -> Dict[str, Any]:
"""Check bending stresses per IS 801:1975."""
Lx = span
Ly = span / (sag_rods + 1) if sag_rods > 0 else span
Sxc = section["Zxx_tp"]
Iyc = section["Iyy"] * 0.5 # Half the moment of inertia for compression flange
Fy = material["Fy"]
E = material["E"]
Zxx_bt = section["Zxx_bt"]
d = section["depth"]
Zyy = section["Zyy"]
# Calculate allowable bending stress for laterally unbraced beam
L2Sxc_dIyc = (Lx**2 * Sxc) / (d * Iyc) if (d * Iyc) > 0 else 0
condition1_limit = (0.18 * math.pi**2 * E) / Fy
condition2_limit = (0.9 * math.pi**2 * E) / Fy
Cb = DEFAULT_CB # Lateral torsional buckling coefficient
if condition1_limit < L2Sxc_dIyc < condition2_limit:
term = ((Fy**2) / (2.7 * math.pi**2 * E * Cb)) * L2Sxc_dIyc
Fb = (2 / 3) * Fy - term
elif L2Sxc_dIyc >= condition2_limit:
Fb = 0.3 * math.pi**2 * E * Cb * (d * Iyc) / (Lx**2 * Sxc) if (Lx**2 * Sxc) > 0 else 0
else:
Fb = 0.6 * Fy
# Basic design stress for minor axis
basic_design_stress = 0.6 * Fy
# Calculate actual bending stresses
sagging_moment = max(moments["M_DL_LL_major"], moments["M_DL_WL_major"], 0)
hogging_moment = min(moments["M_DL_LL_major"], moments["M_DL_WL_major"], 0)
bending_stress_sagging = (sagging_moment * 1e6) / Sxc if Sxc > 0 else 0
bending_stress_hogging = abs((hogging_moment * 1e6) / Zxx_bt) if Zxx_bt > 0 else 0
actual_bending_stress_major = max(bending_stress_sagging, bending_stress_hogging)
minor_moment = max(abs(moments["M_DL_LL_minor"]), abs(moments["M_DL_WL_minor"]))
actual_bending_stress_minor = (minor_moment * 1e6) / Zyy if Zyy > 0 else 0
# Combined bending check per IS 801:1975
combined_ratio = (
(actual_bending_stress_major / Fb) + (actual_bending_stress_minor / basic_design_stress)
if (Fb > 0 and basic_design_stress > 0)
else float("inf")
)
bending_safe = combined_ratio <= 1.0
return {
"bending_stress_major": round(actual_bending_stress_major, 2),
"bending_stress_minor": round(actual_bending_stress_minor, 2),
"allowable_bending_major": round(Fb, 2),
"allowable_bending_minor": round(basic_design_stress, 2),
"combined_bending_ratio": round(combined_ratio, 3),
"bending_safe": bending_safe,
"utilization_ratio": round(combined_ratio, 3),
"spans": {"Lx": Lx, "Ly": Ly},
"is_code_reference": "IS 801:1975, Cl. 6.3 - Bending Stress",
}
def _check_web_crippling(
self, section: Dict[str, Any], material: Dict[str, Any], moments: Dict[str, Any]
) -> Dict[str, Any]:
"""Check web crippling per IS 801:1975."""
t = section["thickness"]
h = section["depth"] - 2 * t # Web height
N = min(h, 25.0) # Bearing length, typically 25mm for purlin supports
Fy_kgf = material["Fy"] * 100 # Convert to kgf/cm² (100 = CM2_TO_MM2)
R = section.get("Rad", 0)
# Convert to cm for formula
t_cm = t / 10
h_cm = h / 10
N_cm = N / 10
R_cm = R / 10
# Validate inputs
if t_cm <= 0 or h_cm <= 0:
return self._create_default_web_crippling_result()
# Calculate ratios with bounds
N_t_ratio = min(N_cm / t_cm, 100) if t_cm > 0 else 0
h_t_ratio = min(h_cm / t_cm, 150) if t_cm > 0 else 0
Fy_ratio = min(max(Fy_kgf / 2320, 0.1), 2.0) if Fy_kgf > 0 else 0.1
# End reaction capacity with bounds checking
term1 = 98 + 4.2 * N_t_ratio
term2 = 0.022 * N_t_ratio * h_t_ratio
term3 = 0.011 * h_t_ratio
bracket_term = term1 - term2 - term3
# Ensure positive bracket term
if bracket_term <= 0:
bracket_term = 10 # Conservative minimum value
factor_term = 1.33 - 0.33 * Fy_ratio
# Ensure positive factor term
if factor_term <= 0:
factor_term = 0.5 # Conservative minimum value
Pmax_end = 70 * t_cm**2 * bracket_term * factor_term * Fy_ratio
# For extreme h_t_ratio > 150, use reduced capacity
if h_t_ratio > 150:
Pmax_end = 70 * t_cm**2 * 50 * Fy_ratio # Simplified conservative formula
# Interior support capacity with bounds checking
term1 = 305 + 2.3 * N_t_ratio
term2 = 0.009 * N_t_ratio * h_t_ratio
term3 = 0.5 * h_t_ratio
bracket_term = term1 - term2 - term3
# Ensure positive bracket term
if bracket_term <= 0:
bracket_term = 50 # Conservative minimum value for interior
factor_term = 1.22 - 0.22 * Fy_ratio
# Ensure positive factor term
if factor_term <= 0:
factor_term = 0.5 # Conservative minimum value
Pmax_interior = 70 * t_cm**2 * bracket_term * factor_term * Fy_ratio
# For extreme h_t_ratio > 150, use reduced capacity
if h_t_ratio > 150:
Pmax_interior = 70 * t_cm**2 * 100 * Fy_ratio # Simplified conservative formula
# Radius modification with bounds
if R_cm > t_cm and R_cm > 0:
radius_factor_end = max(1.15 - 0.15 * (R_cm / t_cm), 0.5)
radius_factor_int = max(1.06 - 0.06 * (R_cm / t_cm), 0.5)
Pmax_end *= radius_factor_end
Pmax_interior *= radius_factor_int
# Convert to kN and ensure positive values
Pmax_end_kN = max(Pmax_end * KGF_TO_N / 1000, 0.1)
Pmax_interior_kN = max(Pmax_interior * KGF_TO_N / 1000, 0.1)
Pmax_kN = min(Pmax_end_kN, Pmax_interior_kN)
controlling_case = "End Reaction" if Pmax_end_kN <= Pmax_interior_kN else "Interior Support"
# Applied shear force
shear_force = moments["max_shear"]
web_crippling_ok = shear_force <= Pmax_kN
utilization_ratio = shear_force / Pmax_kN if Pmax_kN > 0 else float("inf")
return {
"Pmax": round(Pmax_kN, 2),
"Pmax_end": round(Pmax_end_kN, 2),
"Pmax_interior": round(Pmax_interior_kN, 2),
"controlling_case": controlling_case,
"shear_force": round(shear_force, 2),
"web_crippling_ok": web_crippling_ok,
"utilization_ratio": round(utilization_ratio, 3),
"is_code_reference": "IS 801:1975, Cl. 6.5 - Web Crippling",
}
def _create_default_web_crippling_result(self) -> Dict[str, Any]:
"""Create default web crippling result for invalid inputs."""
return {
"Pmax": 0.1,
"Pmax_end": 0.1,
"Pmax_interior": 0.1,
"controlling_case": "End Reaction",
"shear_force": 0.0,
"web_crippling_ok": False,
"utilization_ratio": 999.9,
"is_code_reference": "IS 801:1975, Cl. 6.5 - Web Crippling",
}
def _check_deflection_major(
self,
section: Dict[str, Any],
material: Dict[str, Any],
loads: Dict[str, Any],
span: float,
) -> Dict[str, Any]:
"""Check major axis deflection."""
L = span * 1000 # Convert to mm
W = loads["DL_major"] + max(loads["LL_major"], loads["WL_major"]) # N/mm
I = section["Ixx"] # mm⁴
E = material["E"] # MPa = N/mm²
# Deflection for simply supported beam with UDL
actual_deflection = (5 * W * L**4) / (384 * E * I) if (E * I) > 0 else float("inf")
permissible_deflection = L / 150 # L/150 per IS 801:1975
deflection_safe = actual_deflection <= permissible_deflection
deflection_ratio = f"L/{int(L / actual_deflection)}" if actual_deflection > 0 else "L/∞"
utilization_ratio = (
actual_deflection / permissible_deflection
if permissible_deflection > 0
else float("inf")
)
return {
"actual_deflection": round(actual_deflection, 2),
"permissible_deflection": round(permissible_deflection, 2),
"deflection_safe": deflection_safe,
"deflection_ratio": deflection_ratio,
"utilization_ratio": round(utilization_ratio, 3),
"is_code_reference": "IS 801:1975, Cl. 8.2 - Deflection Limits",
}
def _check_deflection_minor(
self,
section: Dict[str, Any],
material: Dict[str, Any],
loads: Dict[str, Any],
span: float,
sag_rods: int,
) -> Dict[str, Any]:
"""Check minor axis deflection."""
Ly = span / (sag_rods + 1) if sag_rods > 0 else span
L = Ly * 1000 # Convert to mm
W = loads["DL_minor"] + max(loads["LL_minor"], loads["WL_minor"]) # N/mm
I = section["Iyy"] # mm⁴
E = material["E"] # MPa = N/mm²
# Deflection for simply supported beam with UDL
actual_deflection = (5 * W * L**4) / (384 * E * I) if (E * I) > 0 else float("inf")
permissible_deflection = L / 150 # L/150 per IS 801:1975
deflection_safe = actual_deflection <= permissible_deflection
deflection_ratio = f"L/{int(L / actual_deflection)}" if actual_deflection > 0 else "L/∞"
utilization_ratio = (
actual_deflection / permissible_deflection
if permissible_deflection > 0
else float("inf")
)
return {
"actual_deflection": round(actual_deflection, 2),
"permissible_deflection": round(permissible_deflection, 2),
"deflection_safe": deflection_safe,
"deflection_ratio": deflection_ratio,
"utilization_ratio": round(utilization_ratio, 3),
"is_code_reference": "IS 801:1975, Cl. 8.2 - Deflection Limits",
}
def _generate_recommendations(
self,
design_safe: bool,
critical_check: str,
max_utilization: float,
checks: List[Any],
) -> list[str]:
"""Generate design recommendations."""
recommendations = []
if not design_safe:
recommendations.append(
f"❌ Design FAILS - Critical check: {critical_check.replace('_', ' ').title()}"
)
# Specific recommendations based on critical check
if critical_check == "stiffener":
recommendations.append(
"• Increase lip depth or consider different stiffener configuration"
)
recommendations.append("• Check flange width-to-thickness ratios")
elif critical_check == "bending":
recommendations.append("• Increase section depth or select stronger section")
recommendations.append(
"• Consider additional lateral supports to reduce effective length"
)
elif critical_check == "web_crippling":
recommendations.append("• Increase section thickness or provide bearing stiffeners")
recommendations.append("• Consider load distribution over greater length")
elif "deflection" in critical_check:
recommendations.append("• Increase moment of inertia or select deeper section")
recommendations.append("• Consider reducing span or adding intermediate supports")
else:
recommendations.append(
f"✅ Design is SAFE - Maximum utilization: {max_utilization:.1%}"
)
if max_utilization > 0.8:
recommendations.append(
"• High utilization - consider optimization for better economy"
)
elif max_utilization < 0.5:
recommendations.append("• Conservative design - smaller section may be adequate")
# Add general recommendations
recommendations.append("• Verify connection details and end restraints")
recommendations.append("• Consider constructability and deflection under service loads")
return recommendations
def _generate_code_compliance(self, checks: List[Any]) -> Dict[str, Any]:
"""Generate IS 801:1975 code compliance summary."""
return {
"applicable_standard": "IS 801:1975 - Code of Practice for Use of Cold-formed Light Gauge Steel Structural Members in General Building Construction",
"design_method": "Working Stress Design Method",
"load_factors": {
"dead_load": 1.0,
"live_load": 1.0,
"wind_load": 1.0,
"note": "Working stress method - no load factors applied",
},
"safety_factors": {
"material_strength": "Built into allowable stresses",
"deflection_limit": "L/150 for general construction",
},
"verification_status": {
"stiffener_requirements": "Cl. 6.2 - Checked",
"bending_stress": "Cl. 5.1 - Checked",
"web_crippling": "Cl. 7.3 - Checked",
"deflection_limits": "Cl. 8.2 - Checked",
},
}
def _calculate_effective_width(
self, w: float, t: float, Fy: float, calculation_type: str = "load"
) -> tuple[float, bool]:
"""
Calculate effective design width per IS 801:1975 Cl. 5.2.1.1.
Args:
w: Flat width in mm
t: Thickness in mm
Fy: Yield strength in MPa
calculation_type: "load" or "deflection"
Returns:
Tuple of (effective_width, is_fully_effective)
"""
if t <= 0 or Fy <= 0:
return w, True
# Convert Fy to kgf/cm² for IS 801 formulas
Fy_kgf = Fy * KGF_CM2_TO_N_MM2
sqrt_F = math.sqrt(Fy_kgf)
w_t_ratio = w / t
if calculation_type == "load":
# For load determination
w_t_limit = 1435 / sqrt_F
if w_t_ratio <= w_t_limit:
return w, True # Fully effective
else:
# Apply reduction formula
term = 1 - (465 / (w_t_ratio * sqrt_F))
if term <= 0:
return 0.0, False
b_t = (2120 / sqrt_F) * math.sqrt(term)
b_effective = b_t * t
return min(b_effective, w), False
elif calculation_type == "deflection":
# For deflection determination
w_t_limit = 1850 / sqrt_F
if w_t_ratio <= w_t_limit:
return w, True # Fully effective
else:
# Apply reduction formula
term = 1 - (600 / (w_t_ratio * sqrt_F))
if term <= 0:
return 0.0, False
b_t = (2710 / sqrt_F) * math.sqrt(term)
b_effective = b_t * t
return min(b_effective, w), False
return w, True
def _check_flat_width_ratios(
self, section: Dict[str, Any], material: Dict[str, Any]
) -> Dict[str, Any]:
"""
Check flat-width ratios and calculate effective widths per IS 801:1975.
Args:
section: Section properties dictionary
material: Material properties dictionary
Returns:
Dictionary with effective width results
"""
t = section["thickness"]
Fy = material["Fy"]
# Calculate flat widths (excluding fillets and lips)
flange_width_top = section["flange_width_tp"] - 2 * section.get("Rad", 0)
flange_width_bottom = section["flange_width_bt"] - 2 * section.get("Rad", 0)
web_height = section["depth"] - 2 * t
# Check effective widths for load calculations
b_eff_top_load, top_fully_eff_load = self._calculate_effective_width(
flange_width_top, t, Fy, "load"
)
b_eff_bottom_load, bottom_fully_eff_load = self._calculate_effective_width(
flange_width_bottom, t, Fy, "load"
)
# Check effective widths for deflection calculations
b_eff_top_defl, top_fully_eff_defl = self._calculate_effective_width(
flange_width_top, t, Fy, "deflection"
)
b_eff_bottom_defl, bottom_fully_eff_defl = self._calculate_effective_width(
flange_width_bottom, t, Fy, "deflection"
)
# Web is considered stiffened (between flanges)
web_w_t_ratio = web_height / t if t > 0 else 0
return {
"flange_top": {
"flat_width": flange_width_top,
"w_t_ratio": flange_width_top / t if t > 0 else 0,
"effective_width_load": b_eff_top_load,
"effective_width_deflection": b_eff_top_defl,
"fully_effective_load": top_fully_eff_load,
"fully_effective_deflection": top_fully_eff_defl,
},
"flange_bottom": {
"flat_width": flange_width_bottom,
"w_t_ratio": flange_width_bottom / t if t > 0 else 0,
"effective_width_load": b_eff_bottom_load,
"effective_width_deflection": b_eff_bottom_defl,
"fully_effective_load": bottom_fully_eff_load,
"fully_effective_deflection": bottom_fully_eff_defl,
},
"web": {
"flat_width": web_height,
"w_t_ratio": web_w_t_ratio,
"stiffened": True, # Web is stiffened between flanges
"effective_width": web_height, # Web assumed fully effective for stiffened case
},
}
def _get_available_profiles(self, profile_type: Optional[str] = None) -> Dict[str, List[Any]]:
"""
Get list of available profiles, optionally filtered by type.
Args:
profile_type: Optional filter by profile type ('Z45', 'Z90', 'C')
Returns:
Dictionary with profile types as keys and lists of profile names as values
"""
available = {}
if profile_type is None:
# Return all types
for ptype in ALL_PROFILES.keys():
available[ptype] = [profile.name for profile in ALL_PROFILES[ptype]]
else:
# Return specific type
if profile_type in ALL_PROFILES:
available[profile_type] = [profile.name for profile in ALL_PROFILES[profile_type]]
return available