Source code for api.app.calculators.concrete.crack_width

"""
Enhanced Crack Width Calculator per IS 456:2000 Annex F.

Provides contextual safety reporting based on crack width ranges.
"""

import math
from typing import Any, ClassVar, Dict, List, Tuple

from ..base import BaseCalculator


[docs] class CrackWidthCalculator(BaseCalculator): """ IS 456:2000 Annex F crack width calculation with contextual safety. Safety Reporting: - < 0.1mm: SAFE for all exposures - 0.1-0.2mm: SAFE for moderate & severe only - 0.2-0.3mm: SAFE for moderate only - > 0.3mm: UNSAFE for all exposures """ # Simplified exposure conditions (3 types) ALLOWABLE_CRACK_WIDTH: ClassVar[Dict[str, float]] = { "moderate": 0.3, # mm - Moderate exposure "severe": 0.2, # mm - Severe exposure "extreme": 0.1, # mm - Extreme exposure (in water) } # Material properties E_S = 200000.0 # N/mm² - Modulus of elasticity of steel (IS 456 Cl. 5.6.3)
[docs] def __init__(self) -> None: """Initialize calculator.""" super().__init__("crack_width", "2.0.0") self.code_references = [ "IS 456:2000 Annex F (Crack Width Calculation)", "IS 456:2000 Cl. 35.3.2 (Serviceability Limits)", "IS 456:2000 Cl. 5.6.3 (Modulus of Elasticity)", ]
@property def input_schema(self) -> Any: """Return None - using Dict-based validation.""" return None @property def output_schema(self) -> Any: """Return None - using Dict-based output.""" return None
[docs] def get_name(self) -> str: """Get calculator name.""" return "Crack Width Calculator - IS 456:2000 Annex F"
[docs] def validate_inputs(self, raw_inputs: Dict[str, Any]) -> Dict[str, Any]: """ Validate input parameters for crack width calculation. Since this calculator uses Dict-based validation instead of Pydantic, we override the base class validation to perform manual validation. Args: raw_inputs: Raw input dictionary Returns: Validated input dictionary Raises: ValueError: If required inputs are missing or invalid """ element_type = raw_inputs.get("element_type", "beam") if element_type == "beam": required_fields = ["b", "depth", "c", "f_ck", "num_bars1", "db1", "m"] for field in required_fields: if field not in raw_inputs or raw_inputs[field] is None: raise ValueError(f"Missing required field for beam: {field}") # Validate positive values if raw_inputs.get("b", 0) <= 0: raise ValueError("Beam width (b) must be positive") if raw_inputs.get("depth", 0) <= 0: raise ValueError("Beam depth must be positive") if raw_inputs.get("num_bars1", 0) < 1: raise ValueError("Number of bars must be at least 1") if raw_inputs.get("db1", 0) <= 0: raise ValueError("Bar diameter must be positive") if raw_inputs.get("m", 0) <= 0: raise ValueError("Service moment must be positive") elif element_type == "slab": required_fields = ["depth", "c", "f_ck", "spacing", "db_slab", "m_slab"] for field in required_fields: if field not in raw_inputs or raw_inputs[field] is None: raise ValueError(f"Missing required field for slab: {field}") # Validate positive values if raw_inputs.get("depth", 0) <= 0: raise ValueError("Slab depth must be positive") if raw_inputs.get("spacing", 0) <= 0: raise ValueError("Bar spacing must be positive") if raw_inputs.get("db_slab", 0) <= 0: raise ValueError("Slab bar diameter must be positive") if raw_inputs.get("m_slab", 0) <= 0: raise ValueError("Service moment must be positive") else: raise ValueError(f"Invalid element type: {element_type}. Must be 'beam' or 'slab'") return raw_inputs
[docs] def calculate(self, inputs: Dict[str, Any]) -> Dict[str, Any]: """ Main calculation method. Args: inputs: Dictionary with keys (frontend format): - element_type (str): "beam" or "slab" - b (float): Width of beam (mm) - depth (float): Overall depth (mm) - c (float): Clear cover (mm) - f_ck (float): Characteristic compressive strength (MPa) - f_y (float): Yield strength of steel (MPa) - m (float): Service moment for beam (kN-m) - m_slab (float): Service moment for slab (kN-m/m) - num_bars1 (int): Number of bars in layer 1 - db1 (float): Diameter of layer 1 bars (mm) - num_bars2 (int): Number of bars in layer 2 - db2 (float): Diameter of layer 2 bars (mm) - spacing (float): Bar spacing for slab (mm) - db_slab (float): Bar diameter for slab (mm) Returns: Dictionary with crack width results and contextual safety """ # Transform frontend inputs to calculation format transformed = self._transform_inputs(inputs) # Extract transformed inputs b = float(transformed["width"]) h = float(transformed["depth"]) d = float(transformed["effective_depth"]) cover = float(transformed["cover"]) db = float(transformed["bar_diameter"]) spacing = float(transformed["bar_spacing"]) As = float(transformed["steel_area"]) M_service = float(transformed["service_moment"]) concrete_grade = transformed.get("concrete_grade", "M25") exposure = transformed.get("exposure", "moderate") # Validate exposure exposure = self._validate_exposure(exposure) # Extract fck fck = float(concrete_grade.replace("M", "")) # Step 1: Calculate neutral axis depth x = self._calculate_neutral_axis(b, d, As) # Step 2: Calculate stress in steel at service loads f_s, stress_method = self._calculate_stress_in_steel(M_service, As, d, x) # Step 3: Calculate strain at bottom fiber (ε₁) epsilon_1 = f_s / self.E_S # Step 4: Calculate distance to nearest bar (a_cr) a_cr = self._calculate_acr(cover, db, spacing) # Step 5: Calculate average strain (ε_m) - Tension stiffening epsilon_m = self._calculate_average_strain(epsilon_1, b, h, x, h, self.E_S, As, d) # Step 6: Calculate crack width (w_cr) - 3 decimal places w_cr = self._calculate_crack_width(a_cr, epsilon_m, cover + db / 2, h, x) # Step 7: Contextual safety assessment safety_assessment = self._assess_contextual_safety(w_cr, exposure) # Compile results results = { # Primary results (3 decimal places for crack width) "crack_width_calculated": round(w_cr, 3), "crack_width_allowable": self.ALLOWABLE_CRACK_WIDTH[exposure], # Contextual safety "status": safety_assessment["status"], "status_message": safety_assessment["message"], "safe_for_exposures": safety_assessment["safe_exposures"], "utilization_ratio": round(safety_assessment["utilization"], 1), # Backward compatibility fields "is_safe": safety_assessment["status"] == "SAFE", "crack_width": round(w_cr, 3), # Legacy field name # Frontend expected fields "fst": round(f_s, 2), # Steel stress "fst_limit": 0.8 * float(transformed.get("f_y", 415)), # Allowable stress (0.8 fy) "effective_depth": round(d, 2), "steel_area": round(As, 2), "neutral_axis_depth": round(x, 2), "element_type": transformed.get("element_type", "beam"), "highlight": "SAFE" if safety_assessment["status"] == "SAFE" else "FAIL", "remarks": self._generate_remarks(w_cr, exposure, f_s, transformed.get("f_y", 415)), # Intermediate values for traceability "intermediate_values": { "neutral_axis_depth": round(x, 2), "stress_in_steel": round(f_s, 2), "stress_calculation_method": stress_method, "strain_at_bottom": round(epsilon_1, 6), "average_strain": round(epsilon_m, 6), "distance_to_nearest_bar": round(a_cr, 2), }, # Code references "code_references": self.code_references, "exposure_condition": exposure, } return results
def _generate_remarks(self, w_cr: float, exposure: str, f_s: float, f_y: float) -> List[str]: """Generate design remarks based on crack width results.""" remarks: List[str] = [] allowable = self.ALLOWABLE_CRACK_WIDTH[exposure] if w_cr <= allowable: remarks.append(f"Crack width {w_cr:.3f} mm ≤ {allowable} mm (OK)") else: remarks.append(f"Crack width {w_cr:.3f} mm > {allowable} mm (NOT OK)") # Steel stress check fst_limit = 0.8 * float(f_y) if f_s <= fst_limit: remarks.append(f"Steel stress {f_s:.1f} MPa ≤ 0.8 × {f_y} = {fst_limit:.1f} MPa (OK)") else: remarks.append(f"Steel stress {f_s:.1f} MPa > 0.8 × {f_y} = {fst_limit:.1f} MPa (High)") return remarks def _validate_exposure(self, exposure: str) -> str: """ Validate and normalize exposure condition. Args: exposure: User-provided exposure condition Returns: Normalized exposure (moderate/severe/extreme) """ exposure_lower = exposure.lower().replace(" ", "_") # Handle legacy names if exposure_lower in ["mild", "moderate"]: return "moderate" elif exposure_lower in ["severe", "very_severe"]: return "severe" elif exposure_lower == "extreme": return "extreme" else: # Default to moderate if invalid return "moderate" def _transform_inputs(self, inputs: Dict[str, Any]) -> Dict[str, Any]: """ Transform frontend input format to calculation format. Handles both beam and slab element types. Args: inputs: Frontend format inputs Returns: Transformed inputs ready for calculation """ # Check if inputs are already in the transformed format if "width" in inputs and "effective_depth" in inputs: return inputs element_type = inputs.get("element_type", "beam") b = float(inputs.get("b", 300)) h = float(inputs.get("depth", 500)) cover = float(inputs.get("c", 25)) f_ck = float(inputs.get("f_ck", 25)) if element_type == "slab": # Slab calculations db = float(inputs.get("db_slab", 10)) M_service = float(inputs.get("m_slab", 10)) spacing = float(inputs.get("spacing", 150)) # Calculate steel area per meter width bars_per_meter = 1000.0 / spacing As = bars_per_meter * math.pi * (db / 2) ** 2 bar_spacing = spacing else: # Beam calculations num_bars1 = int(inputs.get("num_bars1", 3)) db1 = float(inputs.get("db1", 16)) num_bars2 = int(inputs.get("num_bars2", 0)) db2 = float(inputs.get("db2", 0)) M_service = float(inputs.get("m", 50)) # Calculate total steel area As1 = num_bars1 * math.pi * (db1 / 2) ** 2 As2 = num_bars2 * math.pi * (db2 / 2) ** 2 if num_bars2 > 0 and db2 > 0 else 0 As = As1 + As2 # Use primary bar diameter for calculations db = db1 # Calculate bar spacing if num_bars1 > 1: # Clear spacing between bars bar_spacing = (b - 2 * cover - num_bars1 * db1) / (num_bars1 - 1) else: bar_spacing = b - 2 * cover # Calculate effective depth d = h - cover - db / 2 # Build concrete grade string concrete_grade = f"M{int(f_ck)}" # Get steel yield strength f_y = float(inputs.get("f_y", 415)) return { "width": b, "depth": h, "effective_depth": d, "cover": cover, "bar_diameter": db, "bar_spacing": bar_spacing, "steel_area": As, "service_moment": M_service, "concrete_grade": concrete_grade, "f_y": f_y, "element_type": element_type, "exposure": inputs.get("exposure", "moderate"), } def _assess_contextual_safety(self, w_cr: float, current_exposure: str) -> Dict[str, Any]: """ Assess crack width safety with contextual reporting. Safety ranges: - < 0.1mm: SAFE (all exposures) - 0.1-0.2mm: SAFE (moderate & severe only) - 0.2-0.3mm: SAFE (moderate only) - > 0.3mm: UNSAFE (all exposures) Args: w_cr: Calculated crack width (mm) current_exposure: Exposure condition being checked Returns: Dictionary with status, message, safe exposures list """ safe_exposures: List[str] = [] # Determine which exposures this crack width is safe for if w_cr < 0.1: safe_exposures = ["moderate", "severe", "extreme"] status = "SAFE" message = f"Crack width {w_cr:.3f} mm < 0.1 mm - SAFE for all exposures" elif 0.1 <= w_cr < 0.2: safe_exposures = ["moderate", "severe"] if current_exposure in safe_exposures: status = "SAFE" message = f"Crack width {w_cr:.3f} mm - SAFE for moderate & severe exposures only" else: status = "UNSAFE" message = f"Crack width {w_cr:.3f} mm exceeds limit for extreme exposure (0.1 mm)" elif 0.2 <= w_cr < 0.3: safe_exposures = ["moderate"] if current_exposure == "moderate": status = "SAFE" message = f"Crack width {w_cr:.3f} mm - SAFE for moderate exposure only" else: status = "UNSAFE" limit = 0.2 if current_exposure == "severe" else 0.1 message = f"Crack width {w_cr:.3f} mm exceeds limit for {current_exposure} exposure ({limit} mm)" else: # w_cr >= 0.3 safe_exposures = [] status = "UNSAFE" message = f"Crack width {w_cr:.3f} mm exceeds limit for all exposures (0.3 mm)" # Calculate utilization ratio for current exposure allowable = self.ALLOWABLE_CRACK_WIDTH[current_exposure] utilization = (w_cr / allowable) * 100 return { "status": status, "message": message, "safe_exposures": safe_exposures, "utilization": utilization, } def _calculate_neutral_axis(self, b: float, d: float, As: float) -> float: """ Calculate neutral axis depth for cracked section. Formula: x = [√(m²As² + 2mbdAs) - mAs] / b where m = Es/Ec (modular ratio) Args: b: Width of section (mm) d: Effective depth (mm) As: Area of tension steel (mm²) Returns: Neutral axis depth x (mm) """ m = 10.0 # Simplified modular ratio term1 = (m * As) ** 2 term2 = 2 * m * b * d * As x = (math.sqrt(term1 + term2) - m * As) / b return x def _calculate_stress_in_steel( self, M: float, As: float, d: float, x: float ) -> Tuple[float, str]: """ Calculate stress in steel at service loads. Formula: f_s = M / [As × (d - x/3)] Args: M: Service moment (kN-m) As: Area of tension steel (mm²) d: Effective depth (mm) x: Neutral axis depth (mm) Returns: Tuple of (stress in N/mm², calculation method) Reference: IS 456:2000 Annex F (elastic theory) """ M_Nmm = M * 1e6 # Convert to N-mm z = d - x / 3 # Lever arm f_s = M_Nmm / (As * z) method = "Elastic theory: f_s = M / [As × (d - x/3)]" return f_s, method def _calculate_average_strain( self, epsilon_1: float, b: float, h: float, x: float, a: float, E_s: float, A_s: float, d: float, ) -> float: """ Calculate average strain (Tension Stiffening Effect). Formula: ε_m = ε₁ - [b(h-x)(a-x)] / [3·E_s·A_s·(d-x)] This accounts for concrete contribution in tension between cracks. Args: epsilon_1: Strain at level considered (at bottom fiber) b: Width of section (mm) h: Overall depth (mm) x: Neutral axis depth (mm) a: Distance from compression face to point considered (mm) E_s: Modulus of elasticity of steel (N/mm²) A_s: Area of tension steel (mm²) d: Effective depth (mm) Returns: Average strain ε_m (dimensionless) Reference: IS 456:2000 Annex F, Eq. F-2 """ numerator = b * (h - x) * (a - x) denominator = 3 * E_s * A_s * (d - x) tension_stiffening_reduction = numerator / denominator epsilon_m = epsilon_1 - tension_stiffening_reduction return epsilon_m def _calculate_crack_width( self, a_cr: float, epsilon_m: float, c_min: float, h: float, x: float ) -> float: """ Calculate design surface crack width. Formula: w_cr = [3·a_cr·ε_m] / [1 + 2·((a_cr - c_min)/(h - x))] Args: a_cr: Distance from crack point to nearest bar (mm) epsilon_m: Average strain at crack level c_min: Minimum cover to longitudinal bar (mm) h: Overall depth of section (mm) x: Neutral axis depth (mm) Returns: Crack width in mm Reference: IS 456:2000 Annex F, Eq. F-1 """ numerator = 3 * a_cr * epsilon_m denominator = 1 + 2 * ((a_cr - c_min) / (h - x)) w_cr = numerator / denominator return w_cr def _calculate_acr(self, cover: float, bar_diameter: float, spacing: float) -> float: """ Calculate distance from tension face to nearest bar. Formula: a_cr = cover + bar_diameter/2 Args: cover: Clear cover (mm) bar_diameter: Diameter of bar (mm) spacing: Clear spacing between bars (mm) Returns: Distance a_cr (mm) Reference: IS 456:2000 Annex F (simplified approach) """ a_cr = cover + bar_diameter / 2 return a_cr