"""
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 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
),
},
}