"""
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 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