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

"""
Enhanced Beam Analysis Calculator for StructEngine v2
Supports both determinate and indeterminate beams using IndeterminateBeam library
Follows IS 456:2000 and IS 800:2007 standards

Note:
    - The 'moment' load type in API/frontend is mapped to PointTorque in the indeterminatebeam backend.
        This ensures that applied moments in the UI/API are correctly handled as point torques in analysis.
"""

import time
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type

import numpy as np
import structlog

if TYPE_CHECKING:
    from indeterminatebeam import Beam as IBBeam
    from indeterminatebeam import DistributedLoad as IBDistributedLoad
    from indeterminatebeam import PointLoad as IBPointLoad
    from indeterminatebeam import PointTorque as IBPointTorque
    from indeterminatebeam import Support as IBSupport

try:
    from indeterminatebeam import Beam as IBBeam
    from indeterminatebeam import DistributedLoad as IBDistributedLoad
    from indeterminatebeam import PointLoad as IBPointLoad
    from indeterminatebeam import PointTorque as IBPointTorque
    from indeterminatebeam import Support as IBSupport

    INDETERMINATE_BEAM_AVAILABLE = True
except ImportError:
    INDETERMINATE_BEAM_AVAILABLE = False
    # Define dummy types for runtime when library is not available
    IBBeam = type(None)
    IBSupport = type(None)
    IBPointLoad = type(None)
    IBDistributedLoad = type(None)
    IBPointTorque = type(None)

from pydantic import BaseModel, Field, field_validator, model_validator

from api.app.utils import unit_conversion as uc
from api.app.utils.exceptions import ValidationError

from ..base import BaseCalculator, CalculationError

logger = structlog.get_logger()


[docs] class SupportType(Enum): """Support types as per structural analysis conventions""" PINNED = "pinned" ROLLER = "roller" FIXED = "fixed" FREE = "free"
[docs] class LoadType(Enum): """Load types for beam analysis""" POINT = "point" DISTRIBUTED = "distributed" MOMENT = "moment"
[docs] class BeamAnalysisInput(BaseModel): """Input schema for beam analysis""" span: float = Field(..., gt=0.1, le=50.0, description="Beam span in meters") supports: List[Dict[str, Any]] = Field(..., min_length=1, description="Support definitions") loads: List[Dict[str, Any]] = Field(..., description="Applied loads") material: Dict[str, Any] = Field(..., description="Material properties") section: Dict[str, Any] = Field(..., description="Section properties") analysis_options: Optional[Dict[str, Any]] = Field(default=None, description="Analysis options")
[docs] @field_validator("supports") @classmethod def validate_supports(cls, v: List[Dict[str, Any]]) -> List[Dict[str, Any]]: for support in v: if "position" not in support or "type" not in support: raise ValueError("Each support must have 'position' and 'type'") if support["type"] not in ["pinned", "roller", "fixed", "free"]: raise ValueError("Invalid support type") return v
[docs] @field_validator("loads") @classmethod def validate_loads(cls, v: List[Dict[str, Any]]) -> List[Dict[str, Any]]: for load in v: if "type" not in load or "position" not in load: raise ValueError("Each load must have 'type' and 'position'") if load["type"] not in ["point", "distributed", "moment"]: raise ValueError("Invalid load type") # Validate based on load type if load["type"] == "point": # Point loads can use either force/angle or fx/fy or magnitude has_force_angle = "force" in load and "angle" in load has_fx_fy = "fx" in load or "fy" in load has_magnitude = "magnitude" in load if not (has_force_angle or has_fx_fy or has_magnitude): raise ValueError( "Point loads must have either 'force'+'angle', 'fx'/'fy', or 'magnitude'" ) elif load["type"] in ["distributed", "moment"]: if "magnitude" not in load: raise ValueError(f"{load['type']} loads must have 'magnitude'") return v
[docs] @model_validator(mode="after") def validate_advanced_mode_requirements(self) -> "BeamAnalysisInput": """Validate that required fields are provided when advanced features are enabled. Conditional requirements: - If mode='advanced' and deflection_analysis=True: require elastic_modulus and moment_of_inertia - If include_self_weight=True: require density and area - Basic mode: no material/section property requirements (uses equilibrium only) """ analysis_opts = self.analysis_options or {} mode = analysis_opts.get("mode", "basic") # Validate mode value if mode not in ["basic", "advanced"]: raise ValueError(f"Invalid mode '{mode}'. Must be 'basic' or 'advanced'") # Check deflection analysis requirements in advanced mode if mode == "advanced" and analysis_opts.get("deflection_analysis", False): # Require elastic modulus if not self.material.get("elastic_modulus"): raise ValueError( "elastic_modulus is required in material properties when deflection_analysis " "is enabled in advanced mode" ) # Require moment of inertia (check both direct field and dimensions for custom sections) section_type = self.section.get("type", "custom") has_moment_of_inertia = False if "moment_of_inertia" in self.section: has_moment_of_inertia = True elif section_type in ["rectangular", "circular"]: # These types can calculate I from dimensions dimensions = self.section.get("dimensions", {}) if ( section_type == "rectangular" and "width" in dimensions and "height" in dimensions ): has_moment_of_inertia = True elif section_type == "circular" and "diameter" in dimensions: has_moment_of_inertia = True if not has_moment_of_inertia: raise ValueError( "moment_of_inertia (or sufficient dimensions to calculate it) is required in " "section properties when deflection_analysis is enabled in advanced mode" ) # Check self-weight requirements (applies to both basic and advanced modes) if analysis_opts.get("include_self_weight", False): # Require density if not self.material.get("density"): raise ValueError( "density is required in material properties when include_self_weight is enabled" ) # Require area (check both direct field and dimensions) section_type = self.section.get("type", "custom") has_area = False if self.section.get("dimensions", {}).get("area"): has_area = True elif section_type in ["rectangular", "circular"]: # These types can calculate A from dimensions dimensions = self.section.get("dimensions", {}) if ( section_type == "rectangular" and "width" in dimensions and "height" in dimensions ): has_area = True elif section_type == "circular" and "diameter" in dimensions: has_area = True if not has_area: raise ValueError( "area (or sufficient dimensions to calculate it) is required in section " "properties when include_self_weight is enabled" ) return self
[docs] class BeamAnalysisOutput(BaseModel): """Output schema for beam analysis""" beam_info: Dict[str, Any] reactions: Dict[str, Any] maximum_values: Dict[str, float] diagrams: Dict[str, List[Dict[str, float]]] calculation_summary: Dict[str, Any]
[docs] class BeamAnalysisCalculator(BaseCalculator): """ Enhanced Beam Analysis Calculator for StructEngine v2 Supports both statically determinate and indeterminate beams Complies with IS 456:2000 and IS 800:2007 standards """
[docs] def __init__(self) -> None: super().__init__("beam_analysis", "2.0.0")
def _convert_inputs_to_newtons(self, inputs: Dict[str, Any]) -> Dict[str, Any]: """ Convert user-facing kN inputs to internal SI (N) units. This is the INPUT BOUNDARY conversion layer. Conversions: - Point loads: kN → N (magnitude, force) - Distributed loads: kN/m → N/m (magnitude, endMagnitude) - Moments: kN·m → N·m (magnitude) - Material density: kN/m³ → N/m³ (γ) Args: inputs: Input dictionary with values in kN, kN/m, kN·m, kN/m³ Returns: Converted dictionary with values in N, N/m, N·m, N/m³ """ converted = inputs.copy() # Convert loads: kN → N, kN/m → N/m, kN·m → N·m if "loads" in converted: converted_loads = [] for load in converted["loads"]: load_copy = load.copy() load_type = load.get("type") if load_type == "point": # Convert point load from kN to N if "magnitude" in load_copy: load_copy["magnitude"] = uc.kn_to_n(load_copy["magnitude"]) if "force" in load_copy: load_copy["force"] = uc.kn_to_n(load_copy["force"]) if "fx" in load_copy: load_copy["fx"] = uc.kn_to_n(load_copy["fx"]) if "fy" in load_copy: load_copy["fy"] = uc.kn_to_n(load_copy["fy"]) elif load_type == "distributed": # Convert distributed load from kN/m to N/m if "magnitude" in load_copy: load_copy["magnitude"] = uc.knpm_to_npm(load_copy["magnitude"]) if "endMagnitude" in load_copy: load_copy["endMagnitude"] = uc.knpm_to_npm(load_copy["endMagnitude"]) elif load_type == "moment": # Convert moment from kN·m to N·m if "magnitude" in load_copy: load_copy["magnitude"] = uc.knm_to_nm(load_copy["magnitude"]) converted_loads.append(load_copy) converted["loads"] = converted_loads # Convert material density from kN/m³ to N/m³ if "material" in converted and "density" in converted["material"]: converted["material"] = converted["material"].copy() converted["material"]["density"] = uc.gamma_knpm3_to_npm3( converted["material"]["density"] ) return converted def _convert_outputs_to_kilonewtons(self, results: Dict[str, Any]) -> Dict[str, Any]: """ Convert internal SI (N) results to user-facing kN units. This is the OUTPUT BOUNDARY conversion layer. Conversions: - Reactions: N → kN, N·m → kN·m - Shear force diagrams: N → kN - Bending moment diagrams: N·m → kN·m - Normal force diagrams: N → kN - Maximum values: N → kN, N·m → kN·m - Total load: N → kN - Critical sections: N → kN, N·m → kN·m Args: results: Results dictionary with values in N, N/m, N·m Returns: Converted dictionary with values in kN, kN/m, kN·m """ converted = results.copy() # Convert reactions: N → kN, N·m → kN·m if "reactions" in converted: converted_reactions = {} for pos, reaction in converted["reactions"].items(): converted_reactions[pos] = uc.convert_reaction_dict_to_kn(reaction) converted["reactions"] = converted_reactions # Convert maximum values if "maximum_values" in converted: max_vals = converted["maximum_values"].copy() if "max_shear" in max_vals: max_vals["max_shear"] = round(uc.n_to_kn(max_vals["max_shear"]), 2) if "max_moment" in max_vals: max_vals["max_moment"] = round(uc.nm_to_knm(max_vals["max_moment"]), 2) # max_deflection is already in mm, max_stress is in MPa - no conversion needed converted["maximum_values"] = max_vals # Convert diagram data: N → kN, N·m → kN·m if "diagrams" in converted: diagrams = converted["diagrams"].copy() if "shear_force" in diagrams: diagrams["shear_force"] = uc.convert_diagram_points_to_kn(diagrams["shear_force"]) if "bending_moment" in diagrams: diagrams["bending_moment"] = uc.convert_diagram_points_to_knm( diagrams["bending_moment"] ) if "normal_force" in diagrams: diagrams["normal_force"] = uc.convert_diagram_points_to_kn(diagrams["normal_force"]) # deflection is already in mm - no conversion needed # Update Plotly charts - convert data and update axis labels if diagrams.get("plotly_charts"): plotly_charts = diagrams["plotly_charts"].copy() # Update shear force chart if plotly_charts.get("shear_force"): sf_chart = plotly_charts["shear_force"] if "data" in sf_chart and len(sf_chart["data"]) > 0: sf_chart["data"][0]["y"] = uc.convert_array_n_to_kn( sf_chart["data"][0]["y"] ) sf_chart["data"][0]["hovertemplate"] = ( "Position: %{x:.2f}m<br>Shear Force: %{y:.2f} kN<extra></extra>" ) if "layout" in sf_chart and "yaxis" in sf_chart["layout"]: sf_chart["layout"]["yaxis"]["title"] = "Shear Force (kN)" # Update bending moment chart if plotly_charts.get("bending_moment"): bm_chart = plotly_charts["bending_moment"] if "data" in bm_chart and len(bm_chart["data"]) > 0: bm_chart["data"][0]["y"] = uc.convert_array_nm_to_knm( bm_chart["data"][0]["y"] ) bm_chart["data"][0]["hovertemplate"] = ( "Position: %{x:.2f}m<br>Moment: %{y:.2f} kN·m<extra></extra>" ) if "layout" in bm_chart and "yaxis" in bm_chart["layout"]: bm_chart["layout"]["yaxis"]["title"] = "Bending Moment (kN·m)" # Update normal force chart if plotly_charts.get("normal_force"): nf_chart = plotly_charts["normal_force"] if "data" in nf_chart and len(nf_chart["data"]) > 0: nf_chart["data"][0]["y"] = uc.convert_array_n_to_kn( nf_chart["data"][0]["y"] ) nf_chart["data"][0]["hovertemplate"] = ( "Position: %{x:.2f}m<br>Axial Force: %{y:.2f} kN<extra></extra>" ) if "layout" in nf_chart and "yaxis" in nf_chart["layout"]: nf_chart["layout"]["yaxis"]["title"] = "Axial Force (kN)" # Update max_values in plotly_charts metadata if "max_values" in plotly_charts: max_vals_meta = plotly_charts["max_values"].copy() if max_vals_meta.get("shear"): max_vals_meta["shear"]["value"] = uc.n_to_kn( max_vals_meta["shear"]["value"] ) max_vals_meta["shear"]["unit"] = "kN" if max_vals_meta.get("moment"): max_vals_meta["moment"]["value"] = uc.nm_to_knm( max_vals_meta["moment"]["value"] ) max_vals_meta["moment"]["unit"] = "kN·m" if max_vals_meta.get("normal"): max_vals_meta["normal"]["value"] = uc.n_to_kn( max_vals_meta["normal"]["value"] ) max_vals_meta["normal"]["unit"] = "kN" plotly_charts["max_values"] = max_vals_meta diagrams["plotly_charts"] = plotly_charts converted["diagrams"] = diagrams # Convert calculation summary if "calculation_summary" in converted: summary = converted["calculation_summary"].copy() if "total_load" in summary: summary["total_load"] = round(uc.n_to_kn(summary["total_load"]), 2) if "critical_sections" in summary: converted_sections = [] for section in summary["critical_sections"]: section_copy = section.copy() if section["type"] == "Maximum Shear": section_copy["value"] = round(uc.n_to_kn(section["value"]), 2) elif section["type"] == "Maximum Moment": section_copy["value"] = round(uc.nm_to_knm(section["value"]), 2) converted_sections.append(section_copy) summary["critical_sections"] = converted_sections converted["calculation_summary"] = summary return converted @property def input_schema(self) -> Type[BaseModel]: return BeamAnalysisInput @property def output_schema(self) -> Type[BaseModel]: return BeamAnalysisOutput def _safe_float_conversion(self, value: Any) -> float: """Safely convert various types to float including sympy expressions.""" if value is None: return 0.0 # Handle sympy expressions if hasattr(value, "evalf"): try: return float(value.evalf()) except Exception: return 0.0 # Handle lists (take first element if available) if isinstance(value, list): if len(value) > 0: return float(self._safe_float_conversion(value[0])) return 0.0 # Handle regular numeric values try: return float(value) except (TypeError, ValueError): return 0.0
[docs] def calculate(self, inputs: Dict[str, Any]) -> Dict[str, Any]: """ Perform comprehensive beam analysis using IndeterminateBeam library Supports both determinate and indeterminate beams Boundary conversion architecture: - INPUT: Accepts kN, kN/m, kN·m from API → converts to N, N/m, N·m internally - PROCESSING: All calculations use SI units (N, N/m, N·m) for indeterminatebeam - OUTPUT: Converts N, N/m, N·m → kN, kN/m, kN·m before returning """ # Input validation (before conversion) required_fields = ["span", "supports", "loads", "material", "section"] for field in required_fields: if field not in inputs: raise ValidationError(f"Missing required field: {field}") # BOUNDARY: Convert kN inputs to N for internal calculations inputs = self._convert_inputs_to_newtons(inputs) # Validate span if not isinstance(inputs["span"], (int, float)) or inputs["span"] <= 0: raise ValidationError("'span' must be a positive number") # Validate supports and loads are within span span = float(inputs["span"]) has_x_restraint = False for support in inputs["supports"]: pos = support.get("position", None) if pos is None or not (0 <= pos <= span): raise ValidationError(f"Support at invalid position: {pos}") # Check for x-restraint (pinned or fixed) stype = support.get("type", "").lower() if stype in ("pinned", "fixed"): has_x_restraint = True if not has_x_restraint: raise ValidationError( "At least one support must provide an x-restraint (type 'pinned' or 'fixed')" ) for load in inputs["loads"]: pos = load.get("position", None) if pos is not None and not (0 <= pos <= span): raise ValidationError(f"Load at invalid position: {pos}") # Check for IndeterminateBeam availability at runtime to handle test mocking issues try: from indeterminatebeam import Beam, DistributedLoad, PointLoad, PointTorque, Support except ImportError: # Fallback to basic analysis for determinate beams return self._basic_beam_analysis(inputs) start_time = time.time() try: # Use the imported classes span = inputs["span"] # Material and section properties material_props = self._get_material_properties(inputs["material"]) section_props = self._get_section_properties(inputs["section"], material_props) # Set beam properties E = material_props["elastic_modulus"] * 1e9 # Convert GPa to Pa I = section_props["moment_of_inertia"] A = section_props["area"] # Initialize beam with properties ib_beam = Beam(span=span, E=E, I=I, A=A) # Standardize units to N and m natively (per indeterminatebeam API) try: ib_beam.update_units("force", "N") ib_beam.update_units("length", "m") except Exception as e: # nosec B110: unit update is best-effort; log and continue logger.warning("Failed to update units on beam", error=str(e)) # Add supports supports_info = [] support_objects = [] for support_data in inputs["supports"]: position = support_data["position"] support_type = support_data["type"] # Convert to IndeterminateBeam support constraints constraints = self._get_support_constraints(support_type) support_obj = Support(coord=position, fixed=constraints) support_objects.append(support_obj) supports_info.append( {"position": position, "type": support_type, "constraints": constraints} ) ib_beam.add_supports(*support_objects) # Add loads # Calculate total load for checks total_load = 0.0 load_objects = [] for load_data in inputs["loads"]: load_type = load_data["type"] magnitude = load_data["magnitude"] # Already N (native) position = load_data["position"] if load_type == "point": # Handle both new (force/angle) and legacy (fx/fy) formats if "force" in load_data and "angle" in load_data: # New format: force + angle # Preserve original user input for consistent sign convention force_original = load_data["force"] # Keep user's input value angle = load_data["angle"] # Already in degrees # Use negative force for downward direction in library (structural convention) # Use force in N natively (library sign convention) force_library = force_original angle_native = round( float(angle) ) # Convert to native Python int (library requirement) load_obj = PointLoad( force=force_library, coord=position, angle=angle_native ) total_load += abs(force_original) # Track total using absolute value elif "fx" in load_data or "fy" in load_data: # Legacy format: fx/fy components fx = load_data.get("fx", 0) # N fy = load_data.get("fy", 0) # N # Convert fx/fy to force and angle force_magnitude = (fx**2 + fy**2) ** 0.5 if force_magnitude > 0: angle = round( float(np.degrees(np.arctan2(fy, fx))) ) # Convert to native int else: angle = 90 # Default downward # Use negative force for downward direction in library force_library = force_magnitude # N, native sign load_obj = PointLoad(force=force_library, coord=position, angle=angle) total_load += abs(force_magnitude) # Track total using absolute value else: # Fallback: use magnitude with default downward direction magnitude_original = load_data["magnitude"] # N force_library = magnitude_original load_obj = PointLoad(force=force_library, coord=position, angle=90) total_load += abs(magnitude_original) # Track total using absolute value load_objects.append(load_obj) elif load_type == "distributed": end_pos = load_data.get( "endPosition", position + load_data.get("length", span - position) ) # Use original user input magnitudes for consistent sign convention start_mag_original = load_data["magnitude"] # Keep original sign for display end_mag_original = load_data.get("endMagnitude", start_mag_original) # Use native units (N/m) start_mag = start_mag_original end_mag = end_mag_original # Check if it's uniform or varying if abs(start_mag_original - end_mag_original) < 1e-9: # Uniform distributed load - negative for downward direction in library load_obj = DistributedLoad( expr=f"{start_mag}", span=(position, end_pos), angle=90 ) total_load += start_mag * (end_pos - position) else: # Varying distributed load - use linear expression with negative for downward span_length = end_pos - position if span_length > 0: slope = (end_mag - start_mag) / span_length load_obj = DistributedLoad( expr=f"({start_mag} + {slope} * (x - {position}))", span=(position, end_pos), angle=90, ) # For varying load, total load is average magnitude * length avg_magnitude = (start_mag + end_mag) / 2 total_load += avg_magnitude * span_length else: # Fallback to uniform if span is zero load_obj = DistributedLoad( expr=f"{start_mag}", span=(position, end_pos), angle=90 ) total_load += start_mag * (end_pos - position) load_objects.append(load_obj) elif load_type == "moment": # 'moment' load type in API/frontend is mapped to PointTorque in indeterminatebeam # Preserve original user input magnitude for consistent display original_magnitude = load_data["magnitude"] # Keep user's input value # Apply structural engineering sign convention for moment calculation # Positive user input = clockwise moment (sagging), negative in library # Use absolute value and apply negative sign for proper structural behavior moment_value = original_magnitude # N·m, native sign load_obj = PointTorque(force=moment_value, coord=position) load_objects.append(load_obj) # Add self-weight if requested analysis_options = inputs.get("analysis_options") or {} mode = analysis_options.get("mode", "basic") if analysis_options.get("include_self_weight", False): self_weight = self._calculate_self_weight(span, section_props, material_props) sw_load = DistributedLoad(expr=f"{self_weight}", span=(0, span), angle=90) load_objects.append(sw_load) total_load += self_weight * span ib_beam.add_loads(*load_objects) # Perform analysis ib_beam.analyse() # Generate diagram points num_points = analysis_options.get("diagram_points", 100) x_coords = np.linspace(0, span, num_points) # Extract results - handle various return types safely (native N/N·m) shear_values = [] moment_values = [] normal_values = [] for x in x_coords: try: shear_val = ib_beam.get_shear_force(x) moment_val = ib_beam.get_bending_moment(x) normal_val = ib_beam.get_normal_force(x) # Safely convert to float with proper handling shear_values.append(self._safe_float_conversion(shear_val)) moment_values.append(self._safe_float_conversion(moment_val)) normal_values.append(self._safe_float_conversion(normal_val)) except Exception: shear_values.append(0.0) moment_values.append(0.0) normal_values.append(0.0) # Short-circuit deflection calculation in basic mode to preserve API p95 target deflection_values = [] if mode == "advanced" and analysis_options.get("deflection_analysis", False): for x in x_coords: try: deflection_val = ib_beam.get_deflection(x) deflection_values.append(self._safe_float_conversion(deflection_val)) except Exception: deflection_values.append(0.0) # Use indeterminatebeam's native sign convention for charts shear_values = [v for v in shear_values] moment_values = [v for v in moment_values] normal_values = [v for v in normal_values] # Optional sign mirroring (downward/clockwise positive) without altering library mirror = bool((inputs.get("analysis_options") or {}).get("mirror_signs", False)) if mirror: shear_values = [-v for v in shear_values] moment_values = [-v for v in moment_values] normal_values = [-v for v in normal_values] # Generate Plotly-compatible chart data (native or mirrored) plotly_charts = self._generate_plotly_charts( x_coords, shear_values, moment_values, deflection_values, normal_values, span ) # Get reactions reactions = {} for support in supports_info: pos = support["position"] try: reaction_value = ib_beam.get_reaction(pos) # Handle different return types from the library if isinstance(reaction_value, (list, tuple)): reactions[str(pos)] = { "Rx": float(reaction_value[0]) if len(reaction_value) > 0 else 0, "Ry": float(reaction_value[1]) if len(reaction_value) > 1 else 0, "M": float(reaction_value[2]) if len(reaction_value) > 2 else 0, } else: reactions[str(pos)] = { "Rx": 0.0, "Ry": float(reaction_value) if reaction_value is not None else 0, "M": 0.0, } except Exception: reactions[str(pos)] = {"Rx": 0.0, "Ry": 0.0, "M": 0.0} # Calculate maximum values max_shear = max(abs(v) for v in shear_values) if shear_values else 0 max_moment = max(abs(v) for v in moment_values) if moment_values else 0 max_deflection = max(abs(v) for v in deflection_values) if deflection_values else 0 # Check if beam is statically determinate total_reactions = sum( sum(self._get_support_constraints(s["type"])) for s in inputs["supports"] ) is_determinate = total_reactions == 3 degree_of_indeterminacy = max(0, total_reactions - 3) # Track computed outputs and warnings for client-side conditional rendering mode = analysis_options.get("mode", "basic") computed_outputs = { "has_deflection": bool(deflection_values), "has_self_weight": analysis_options.get("include_self_weight", False), "has_stress": mode == "advanced", # Stress calc available in advanced "has_axial": any(abs(v) > 1e-6 for v in normal_values), # Axial forces present } warnings = [] # Warn when advanced features are skipped due to missing inputs if mode == "advanced": if analysis_options.get("deflection_analysis", False) and not deflection_values: warnings.append( "Deflection analysis skipped: elastic_modulus and moment_of_inertia required" ) if ( analysis_options.get("include_self_weight", False) and analysis_options.get("include_self_weight") != computed_outputs["has_self_weight"] ): warnings.append("Self-weight calculation skipped: density and area required") # Prepare results results = { "beam_info": { "length": span, "material_properties": material_props, "section_properties": section_props, "is_determinate": is_determinate, "degree_of_indeterminacy": degree_of_indeterminacy, }, "reactions": reactions, "maximum_values": { "max_shear": round(max_shear, 2), # N "max_moment": round(max_moment, 2), # N·m "max_deflection": round(max_deflection * 1000, 3), # Convert to mm "max_stress": round( self._calculate_basic_stress(max_moment, section_props), 2 ), # MPa }, "diagrams": { "shear_force": [{"x": x, "y": y} for x, y in zip(x_coords, shear_values)], "bending_moment": [{"x": x, "y": y} for x, y in zip(x_coords, moment_values)], "normal_force": [{"x": x, "y": y} for x, y in zip(x_coords, normal_values)], "deflection": ( [{"x": x, "y": y * 1000} for x, y in zip(x_coords, deflection_values)] if deflection_values else [] ), "plotly_charts": plotly_charts, }, "calculation_summary": { "total_load": round(total_load, 2), "critical_sections": self._find_critical_sections( x_coords, shear_values, moment_values ), "warnings": warnings, }, "computed_outputs": computed_outputs, } # BOUNDARY: Convert N outputs to kN for API response return self._convert_outputs_to_kilonewtons(results) except Exception as e: logger.error("Beam analysis calculation failed", error=str(e)) raise CalculationError(f"Calculation failed: {e!s}")
def _basic_beam_analysis(self, inputs: Dict[str, Any]) -> Dict[str, Any]: """Fallback basic analysis for determinate beams when IndeterminateBeam is not available""" span = inputs["span"] # Check if system is determinate total_reactions = sum( sum(self._get_support_constraints(s["type"])) for s in inputs["supports"] ) if total_reactions != 3: raise CalculationError( "IndeterminateBeam library required for indeterminate analysis. Only determinate beams supported in fallback mode." ) # Basic determinate beam analysis (simplified) material_props = self._get_material_properties(inputs["material"]) section_props = self._get_section_properties(inputs["section"], material_props) # Simple reactions calculation for basic cases reactions = self._calculate_simple_reactions(inputs) # Generate basic diagrams x_coords = np.linspace(0, span, 100) shear_values = [self._calculate_basic_shear(x, inputs, reactions) for x in x_coords] moment_values = [self._calculate_basic_moment(x, inputs, reactions) for x in x_coords] max_shear = max(abs(v) for v in shear_values) if shear_values else 0 max_moment = max(abs(v) for v in moment_values) if moment_values else 0 return { "beam_info": { "length": span, "material_properties": material_props, "section_properties": section_props, "is_determinate": True, "degree_of_indeterminacy": 0, }, "reactions": reactions, "maximum_values": { "max_shear": round(max_shear / 1000, 2), "max_moment": round(max_moment / 1000, 2), "max_deflection": 0, # Not calculated in basic mode "max_stress": round( self._calculate_basic_stress(max_moment, section_props), 2 ), # MPa }, "diagrams": { "shear_force": [{"x": x, "y": y / 1000} for x, y in zip(x_coords, shear_values)], "bending_moment": [ {"x": x, "y": y / 1000} for x, y in zip(x_coords, moment_values) ], "deflection": [], # Not calculated in basic mode }, "calculation_summary": { "total_load": sum(load["magnitude"] for load in inputs["loads"]), "critical_sections": self._find_critical_sections( x_coords, shear_values, moment_values ), }, } def _get_support_constraints(self, support_type: str) -> Tuple[int, int, int]: """Convert support type to constraint tuple for IndeterminateBeam""" mapping = {"pinned": (1, 1, 0), "roller": (0, 1, 0), "fixed": (1, 1, 1), "free": (0, 0, 0)} return mapping.get(support_type, (0, 0, 0)) def _get_material_properties(self, material_data: Dict[str, Any]) -> Dict[str, Any]: """ Get material properties from database or defaults. Note: Density (γ) defaults are in kN/m³ for user convenience. These will be converted to N/m³ at the input boundary layer. """ material_type = material_data["type"] grade = material_data["grade"] # Default material properties based on IS codes if material_type == "concrete": E = material_data.get( "elastic_modulus", 5000 * (int(grade[1:]) ** 0.5) ) # IS 456 formula density = 25.0 # kN/m³ (converted to N/m³ at input boundary) elif material_type == "steel": E = material_data.get("elastic_modulus", 200000) # GPa density = 78.5 # kN/m³ (converted to N/m³ at input boundary) else: E = material_data.get("elastic_modulus", 12000) # Default for timber density = 8.0 # kN/m³ (converted to N/m³ at input boundary) return { "type": material_type, "grade": grade, "elastic_modulus": E, # GPa "density": density, # kN/m³ (user-facing unit, converted to N/m³ internally) } def _get_section_properties( self, section_data: Dict[str, Any], material_props: Dict[str, Any] ) -> Dict[str, Any]: """Calculate section properties""" section_type = section_data["type"] dimensions = section_data["dimensions"] if section_type == "rectangular": width = dimensions["width"] # m height = dimensions["height"] # m area = width * height I = width * height**3 / 12 elif section_type == "circular": diameter = dimensions["diameter"] # m area = np.pi * diameter**2 / 4 I = np.pi * diameter**4 / 64 elif section_type == "custom": area = dimensions.get("area", 0.1) I = section_data.get("moment_of_inertia", 8.33e-5) else: # Default rectangular section area = 0.3 * 0.6 I = 8.33e-5 return { "type": section_type, "area": area, # m² "moment_of_inertia": I, # m⁴ "dimensions": dimensions, } def _calculate_self_weight( self, span: float, section_props: Dict[str, Any], material_props: Dict[str, Any] ) -> float: """ Calculate self-weight per unit length. Material density (γ) is accepted in kN/m³ at API, converted to N/m³ at input boundary, so this function receives γ_N/m³ already. Formula: w_N/m = γ_N/m³ · A_m² Args: span: Beam span in meters section_props: Section properties with area in m² material_props: Material properties with density in N/m³ (already converted) Returns: Self-weight per unit length in N/m """ area = section_props["area"] # m² density = material_props["density"] # N/m³ (converted from kN/m³ at input boundary) return float(area) * float(density) # N/m def _calculate_basic_stress(self, max_moment: float, section_props: Dict[str, Any]) -> float: """Calculate basic bending stress for analysis (not design checks)""" I = float(section_props["moment_of_inertia"]) height = float( section_props["dimensions"].get("height", 0.6) ) # Assume height for stress calc if I > 0 and height > 0: max_stress = abs(max_moment) * (height / 2) / I # Basic bending stress in Pa return max_stress / 1e6 # Convert to MPa return 0.0 def _perform_code_checks( self, max_deflection: float, span: float, max_moment: float, section_props: Dict[str, Any], material_props: Dict[str, Any], ) -> Dict[str, Any]: """Perform IS code compliance checks""" # Deflection check (IS 456 Cl. 23.2) allowable_deflection = span / 250 # Basic deflection limit deflection_ratio = ( abs(max_deflection) / allowable_deflection if allowable_deflection > 0 else 0 ) deflection_status = "SAFE" if deflection_ratio <= 1.0 else "UNSAFE" # Stress check (simplified) I = section_props["moment_of_inertia"] height = section_props["dimensions"].get("height", 0.6) # Assume height for stress calc if I > 0 and height > 0: max_stress = abs(max_moment) * (height / 2) / I # Basic bending stress # Simplified allowable stress if material_props["type"] == "concrete": allowable_stress = 10e6 # 10 MPa for concrete else: allowable_stress = 250e6 # 250 MPa for steel stress_ratio = max_stress / allowable_stress stress_status = "SAFE" if stress_ratio <= 1.0 else "UNSAFE" else: max_stress = 0.0 allowable_stress = 0.0 stress_ratio = 0.0 stress_status = "UNKNOWN" return { "deflection_check": { "actual": abs(max_deflection), "allowable": allowable_deflection, "ratio": deflection_ratio, "status": deflection_status, }, "stress_check": { "actual": max_stress, "allowable": allowable_stress, "ratio": stress_ratio, "status": stress_status, }, "max_stress": max_stress, } def _find_critical_sections( self, x_coords: "np.ndarray[Any, np.dtype[np.float64]]", shear_values: List[float], moment_values: List[float], ) -> List[Dict[str, Any]]: """Find critical sections with maximum values""" critical_sections = [] if len(shear_values) > 0 and len(moment_values) > 0: # Maximum shear max_shear_idx = np.argmax([abs(v) for v in shear_values]) critical_sections.append( { "location": round(x_coords[max_shear_idx], 2), "type": "Maximum Shear", "value": round(shear_values[max_shear_idx], 2), # N } ) # Maximum moment max_moment_idx = np.argmax([abs(v) for v in moment_values]) critical_sections.append( { "location": round(x_coords[max_moment_idx], 2), "type": "Maximum Moment", "value": round( moment_values[max_moment_idx], 2 ), # N·m (converted at output boundary) } ) return critical_sections def _calculate_simple_reactions(self, inputs: Dict[str, Any]) -> Dict[str, Any]: """Calculate reactions for simple determinate beams""" # Simplified reaction calculation for basic cases supports = inputs["supports"] loads = inputs["loads"] span = inputs["span"] # Simple case: two supports if len(supports) == 2: support_positions = [s["position"] for s in supports] total_load = sum(load["magnitude"] * 1000 for load in loads) # Convert to N # Take moments about first support moment_sum = sum( load["magnitude"] * 1000 * (load["position"] - support_positions[0]) for load in loads ) R2 = moment_sum / (support_positions[1] - support_positions[0]) R1 = -total_load - R2 return { str(support_positions[0]): {"Rx": 0.0, "Ry": R1, "M": 0.0}, str(support_positions[1]): {"Rx": 0.0, "Ry": R2, "M": 0.0}, } return {} def _calculate_basic_shear( self, x: float, inputs: Dict[str, Any], reactions: Dict[str, Any] ) -> float: """Calculate shear force at position x for basic analysis""" V = 0.0 # Add reactions up to position x for pos_str, reaction in reactions.items(): pos = float(pos_str) if pos <= x: V -= reaction["Ry"] # Negative because reaction is upward # Add loads up to position x for load in inputs["loads"]: if load["type"] == "point" and load["position"] <= x: V += load["magnitude"] * 1000 # Convert kN to N return V def _calculate_basic_moment( self, x: float, inputs: Dict[str, Any], reactions: Dict[str, Any] ) -> float: """Calculate bending moment at position x for basic analysis""" M = 0.0 # Add moment contributions from reactions for pos_str, reaction in reactions.items(): pos = float(pos_str) if pos <= x: M += reaction["Ry"] * (x - pos) # Add moment contributions from loads for load in inputs["loads"]: if load["type"] == "point" and load["position"] <= x: M -= load["magnitude"] * 1000 * (x - load["position"]) # Convert kN to N return M def _generate_plotly_charts( self, x_coords: "np.ndarray[Any, np.dtype[np.float64]]", shear_values: List[float], moment_values: List[float], deflection_values: List[float], normal_values: List[float], span: float, ) -> Dict[str, Any]: """Generate Plotly-compatible chart configurations for interactive visualization""" # Convert coordinates to lists x_list = x_coords.tolist() # Use native units (N, N·m) from the library shear_n = [y for y in shear_values] moment_nm = [y for y in moment_values] normal_n = [y for y in normal_values] # Find max/min values and their positions for annotations if shear_n: max_shear_idx = max(range(len(shear_n)), key=lambda i: abs(shear_n[i])) max_shear_val = shear_n[max_shear_idx] max_shear_pos = x_list[max_shear_idx] else: max_shear_val, max_shear_pos = 0, 0 if moment_nm: max_moment_idx = max(range(len(moment_nm)), key=lambda i: abs(moment_nm[i])) max_moment_val = moment_nm[max_moment_idx] max_moment_pos = x_list[max_moment_idx] else: max_moment_val, max_moment_pos = 0, 0 if normal_n: max_normal_idx = max(range(len(normal_n)), key=lambda i: abs(normal_n[i])) max_normal_val = normal_n[max_normal_idx] max_normal_pos = x_list[max_normal_idx] else: max_normal_val, max_normal_pos = 0, 0 # Shear Force Diagram with max/min annotations shear_chart = { "data": [ { "x": x_list, "y": shear_n, "type": "scatter", "mode": "lines", "name": "Shear Force", "line": {"color": "#26c6da", "width": 3}, "fill": "tozeroy", "fillcolor": "rgba(38, 198, 218, 0.1)", "hovertemplate": "Position: %{x:.2f}m<br>Shear Force: %{y:.2f} N<extra></extra>", } ], "layout": { "title": { "text": "Shear Force Diagram", "font": {"size": 16, "color": "#1f2937"}, }, "xaxis": { "title": "Position (m)", "showgrid": True, "gridcolor": "rgba(0,0,0,0.1)", "range": [0, span], }, "yaxis": { "title": "Shear Force (N)", "showgrid": True, "gridcolor": "rgba(0,0,0,0.1)", "zeroline": True, "zerolinecolor": "#64748b", "zerolinewidth": 2, }, "showlegend": False, "margin": {"l": 60, "r": 40, "t": 50, "b": 50}, "plot_bgcolor": "rgba(0,0,0,0)", "paper_bgcolor": "rgba(0,0,0,0)", "font": {"family": "Inter, sans-serif", "size": 11, "color": "#374151"}, }, "config": { "displayModeBar": True, "displaylogo": False, "modeBarButtonsToRemove": ["pan2d", "select2d", "lasso2d", "autoScale2d"], "responsive": True, }, } # Bending Moment Diagram with max/min annotations moment_chart = { "data": [ { "x": x_list, "y": moment_nm, "type": "scatter", "mode": "lines", "name": "Bending Moment", "line": {"color": "#26c6da", "width": 3}, "fill": "tozeroy", "fillcolor": "rgba(38, 198, 218, 0.1)", "hovertemplate": "Position: %{x:.2f}m<br>Moment: %{y:.2f} N·m<extra></extra>", } ], "layout": { "title": { "text": "Bending Moment Diagram", "font": {"size": 16, "color": "#1f2937"}, }, "xaxis": { "title": "Position (m)", "showgrid": True, "gridcolor": "rgba(0,0,0,0.1)", "range": [0, span], }, "yaxis": { "title": "Bending Moment (N·m)", "showgrid": True, "gridcolor": "rgba(0,0,0,0.1)", "zeroline": True, "zerolinecolor": "#64748b", "zerolinewidth": 2, }, "showlegend": False, "margin": {"l": 60, "r": 40, "t": 50, "b": 50}, "plot_bgcolor": "rgba(0,0,0,0)", "paper_bgcolor": "rgba(0,0,0,0)", "font": {"family": "Inter, sans-serif", "size": 11, "color": "#374151"}, }, "config": { "displayModeBar": True, "displaylogo": False, "modeBarButtonsToRemove": ["pan2d", "select2d", "lasso2d", "autoScale2d"], "responsive": True, }, } # Axial Force Diagram with max/min annotations normal_chart = { "data": [ { "x": x_list, "y": normal_n, "type": "scatter", "mode": "lines", "name": "Axial Force", "line": {"color": "#26c6da", "width": 3}, "fill": "tozeroy", "fillcolor": "rgba(38, 198, 218, 0.1)", "hovertemplate": "Position: %{x:.2f}m<br>Axial Force: %{y:.2f} N<extra></extra>", } ], "layout": { "title": { "text": "Axial Force Diagram", "font": {"size": 16, "color": "#1f2937"}, }, "xaxis": { "title": "Position (m)", "showgrid": True, "gridcolor": "rgba(0,0,0,0.1)", "range": [0, span], }, "yaxis": { "title": "Axial Force (N)", "showgrid": True, "gridcolor": "rgba(0,0,0,0.1)", "zeroline": True, "zerolinecolor": "#64748b", "zerolinewidth": 2, }, "showlegend": False, "margin": {"l": 60, "r": 40, "t": 50, "b": 50}, "plot_bgcolor": "rgba(0,0,0,0)", "paper_bgcolor": "rgba(0,0,0,0)", "font": {"family": "Inter, sans-serif", "size": 11, "color": "#374151"}, }, "config": { "displayModeBar": True, "displaylogo": False, "modeBarButtonsToRemove": ["pan2d", "select2d", "lasso2d", "autoScale2d"], "responsive": True, }, } # Deflection Diagram (if available) with max/min annotations deflection_chart = None max_deflection_val: float = 0.0 max_deflection_pos: float = 0.0 deflection_mm = [] if deflection_values: deflection_mm = [y * 1000 for y in deflection_values] # Find max deflection if deflection_mm: max_deflection_idx = max( range(len(deflection_mm)), key=lambda i: abs(deflection_mm[i]) ) max_deflection_val = deflection_mm[max_deflection_idx] max_deflection_pos = x_list[max_deflection_idx] deflection_chart = { "data": [ { "x": x_list, "y": deflection_mm, "type": "scatter", "mode": "lines", "name": "Deflection", "line": {"color": "#26c6da", "width": 3}, "fill": "tozeroy", "fillcolor": "rgba(38, 198, 218, 0.1)", "hovertemplate": "Position: %{x:.2f}m<br>Deflection: %{y:.3f}mm<extra></extra>", } ], "layout": { "title": { "text": "Deflection Diagram", "font": {"size": 16, "color": "#1f2937"}, }, "xaxis": { "title": "Position (m)", "showgrid": True, "gridcolor": "rgba(0,0,0,0.1)", "range": [0, span], }, "yaxis": { "title": "Deflection (mm)", "showgrid": True, "gridcolor": "rgba(0,0,0,0.1)", "zeroline": True, "zerolinecolor": "#64748b", "zerolinewidth": 2, }, "showlegend": False, "margin": {"l": 60, "r": 40, "t": 50, "b": 50}, "plot_bgcolor": "rgba(0,0,0,0)", "paper_bgcolor": "rgba(0,0,0,0)", "font": {"family": "Inter, sans-serif", "size": 11, "color": "#374151"}, }, "config": { "displayModeBar": True, "displaylogo": False, "modeBarButtonsToRemove": ["pan2d", "select2d", "lasso2d", "autoScale2d"], "responsive": True, }, } return { "shear_force": shear_chart, "bending_moment": moment_chart, "normal_force": normal_chart, "deflection": deflection_chart, "max_values": { "shear": {"value": abs(max_shear_val), "position": max_shear_pos, "unit": "N"}, "moment": { "value": abs(max_moment_val), "position": max_moment_pos, "unit": "N·m", }, "normal": {"value": abs(max_normal_val), "position": max_normal_pos, "unit": "N"}, "deflection": ( {"value": abs(max_deflection_val), "position": max_deflection_pos, "unit": "mm"} if deflection_values else None ), }, }