# CivilPy
# Copyright (C) $originalComment.match("Copyright \(C\) (\d+)", 1)-2026 Dane Parks
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from civilpy.general import PrintColors
from civilpy.structural.midas import analysis_results_request
from pathlib import Path
import pandas as pd
import numpy as np
import pint
import os
from civilpy.structural.midas import (
get_elements_by_section_index,
get_api_key,
midas_api,
)
from civilpy.general import units
future_ballast_depth = 6 * units("in")
design_rail_spacing = 5 * units("ft")
design_rail_height = 8 * units("in")
design_tie_depth = 7 * units("in")
[docs]
class LoadRatingMember: # pragma: no cover
"""
Class for load rating bridge members based on the guidance in AREMA Chapter 15
"""
def __init__(
self,
ballast_cover: pint.Quantity = None,
length: pint.Quantity = None,
multi_track_factor: int = 1,
span_length: pint.Quantity = None,
speed: pint.Quantity = None,
degree_of_curvature: pint.Quantity = None,
super_elevation=None,
distance_to_top_of_rail: pint.Quantity = None,
cl_track_to_cl_girders: pint.Quantity = 0 * units("in"),
rail_spacing: pint.Quantity = design_rail_spacing,
load_cases: list = None,
section_ids: list = None,
element_ids: list = None,
results_request: dict = analysis_results_request,
export_path: str = Path(os.getcwd()) / "output" / "output.json",
dl_case_name: str = "",
ll_case_name: str = "",
rocking_case_name: str = "",
wind_load: bool = True,
load_factor: bool = True,
f_y: pint.Quantity = None,
e_s: pint.Quantity = None,
poissons_ratio: float = None,
l_brace: float = None,
r_yf: float = None,
depth_girder: pint.Quantity = None,
web_thickness: pint.Quantity = None,
area_flange: pint.Quantity = None,
s_x: pint.Quantity = None,
):
# General Properties
self.poisson_ration = poissons_ratio
self.impact_ballast_factor = 0.9 if ballast_cover > 8 * units.inch else 1
self.length = length
self.rail_spacing = rail_spacing
self.speed = speed
self.degree_of_curvature = degree_of_curvature
self.super_elevation = super_elevation
# Impact Loads and Mult-presence factor
self.impact_load = None
self.no_of_tracks = multi_track_factor
# Centrifugal force
self.CF_force = (
0.00117 * (self.speed / units("mph")) ** 2 * self.degree_of_curvature
)
self.CF_percentage = (8 * units("ft") * self.CF_force / rail_spacing) / 100
# Track Eccentricity
self.track_eccentricity = cl_track_to_cl_girders
self.EC = self._calculate_eccentricity()
# Wind Loads
self.wind_height = 8 * units("ft") + distance_to_top_of_rail
self.wind_load_live = 200 * units("lbf/ft")
# Longitudinal Forces
# AREMA 15-1.3.7: Longitudinal forces (braking/traction) act along track.
# For perpendicular members (troughs, floor beams) these forces are typically
# transferred to the girders through the floor system and do not directly load
# the perpendicular member; confirm with project-specific load path analysis.
self.breaking_force = (45 + 1.2 * (span_length / units("ft"))) * units("kip")
self.breaking_load = self.breaking_force / span_length
self.traction_force = 25 * np.sqrt(span_length / units("ft")) * units("kip")
self.traction_height = 3 * units("ft") + distance_to_top_of_rail
# Midas API Inputs
self.load_cases = load_cases
self.section_ids = section_ids
if (
not self.length
): # Forces user to enter a member length if not previously provided
print(
f"{PrintColors.FAIL}Error: Length of member is Required!{PrintColors.ENDC}"
)
self.length = float(input("Length of member (float, in ft.): ")) * units(
"ft"
)
self._get_impact_load()
else:
self._get_impact_load()
if self.no_of_tracks > 1:
self._multi_presence_impact_load()
self.span_length = span_length
self.section_ids = section_ids
get_api_key(Path.home() / "secrets.json")
self.element_ids = (
[
list(get_elements_by_section_index(value)["ELEM"].keys())
for value in self.section_ids
]
if not element_ids
else element_ids
)
# Flatten the previously created list
self.element_ids = [item for sublist in self.element_ids for item in sublist]
self.results_request = results_request
self.export_path = export_path
self.results_request["Argument"]["NODE_ELEMS"]["KEYS"] = [
int(x) for x in self.element_ids
]
self.results_request["Argument"]["LOAD_CASE_NAMES"] = self.load_cases
self.results_request["Argument"]["EXPORT_PATH"] = str(self.export_path)
try:
with open(self.export_path):
pass
except FileNotFoundError as e:
print(
f"Couldn't find file at {self.export_path}, {e}, creating the directory and output file"
)
os.mkdir("output")
with open("output/output.json", "w") as file:
file.write("Your text goes here")
# Get the Analysis Results
self.analysis_results = midas_api("POST", "POST/TABLE", self.results_request)
# Store the results in a dataframe
self.results_df = pd.DataFrame.from_dict(
self.analysis_results["BeamForce"]["DATA"]
)
self.results_df.columns = self.analysis_results["BeamForce"]["HEAD"]
# Start pulling the values you need from the results
self.max_moment_dead_load, self.max_shear_dead_load = self._get_max_load_values(
load_type=dl_case_name
)
self.max_moment_live_load, self.max_shear_live_load = self._get_max_load_values(
load_type=ll_case_name
)
self.max_moment_rocking, self.max_shear_rocking = self._get_max_load_values(
load_type=rocking_case_name
)
# Impact Moments
self.impact_moment = self.max_moment_live_load * self.impact_load
self.impact_shear = self.max_shear_live_load * self.impact_load
self.impact_reduction_factor = (
1 - (0.8 / 2500) * (60 - self.speed / units("mph")) ** 2
)
# Reduce impact based on impact reduction factor
self.M_reduced_impact = self.impact_moment * self.impact_reduction_factor
self.V_reduced_impact = self.impact_shear * self.impact_reduction_factor
# Centrifugal Force
self.M_centrifugal = self.max_moment_live_load * self.CF_percentage
self.V_centrifugal = self.max_shear_live_load * self.CF_percentage
# Superelevation
self.M_super_elevaion = self.max_moment_live_load * self.EC
self.V_super_elevation = self.max_shear_live_load * self.EC
# Wind Loads
if wind_load:
print(
"Wind Load effects have not been accounted for yet, must manually calculate"
)
else:
self.M_wind = 0
self.V_wind = 0
# Load Factor
if load_factor:
print(
"Wind Load effects have not been accounted for yet, must manually calculate"
)
else:
self.M_LF = 0
self.V_LF = 0
# Perform Load Rating
self.allowable_stress = self.F_bt = (0.55 * f_y).to("ksi")
self.F_bc = min(
0.55 * f_y,
max(
0.55 * f_y
- ((0.55 * f_y**2) / (6.3 * np.pi**2 * e_s)) * (l_brace / r_yf) ** 2,
(0.131 * np.pi * e_s)
/ (
(l_brace * depth_girder * np.sqrt(1 + poissons_ratio)).to("in^2")
/ area_flange
).to("dimensionless"),
),
).to("ksi")
self.allowable_bending_stress = self.F_b = min(self.F_bt, self.F_bc).to("ksi")
self.allowable_shear_stress = self.F_v = (0.35 * f_y).to("ksi")
# Load Rating Values
self.E80_M = (
(
(self.F_b * s_x - self.max_moment_dead_load)
/ (
self.max_moment_live_load
+ self.M_reduced_impact
+ self.max_moment_rocking
+ self.M_centrifugal
+ self.M_super_elevaion
+ self.M_wind
+ self.M_LF
)
)
* 80
).to("dimensionless")
self.E80_V = (
(
(self.F_v * (depth_girder * web_thickness) - self.max_shear_dead_load)
/ (
self.max_shear_live_load
+ self.V_reduced_impact
+ self.max_shear_rocking
+ self.V_centrifugal
+ self.V_super_elevation
+ self.V_wind
+ self.V_LF
)
)
* 80
).to("dimensionless")
self.k_max = 0.8 * f_y
self.k_bt = self.k_max
self.k_bc = min(
self.k_max,
max(
self.k_max
- ((self.k_max * f_y / units("psi")) / 1.8e9) * (l_brace / r_yf) ** 2,
(self.k_max * units("psi") / (0.55 * f_y))
* (
10500000
/ ((l_brace * depth_girder) / area_flange).to("dimensionless")
),
),
).to("ksi")
self.k_b = min(self.k_bt, self.k_bc).to("ksi")
self.k_v = (0.75 * self.k_max).to("ksi")
self.max_M = (
(
(self.k_b * s_x - self.max_moment_dead_load)
/ (
self.max_moment_live_load
+ self.max_moment_rocking
+ self.M_super_elevaion
+ self.M_wind
+ self.M_LF
+ self.M_reduced_impact
+ self.M_centrifugal
)
)
* 80
).to("dimensionless")
self.max_V = (
(
(self.k_v * (depth_girder * web_thickness) - self.max_shear_dead_load)
/ (
self.max_shear_live_load
+ self.V_reduced_impact
+ self.max_shear_rocking
+ self.V_centrifugal
+ self.V_super_elevation
+ self.V_wind
+ self.V_LF
)
)
* 80
).to("dimensionless")
# Note: max_moment_live_load / max_shear_live_load should already represent the
# governing combined LL case (LL_i + LL_o for two-track bridges) from the FEM model.
# Verify the Midas load case used captures both tracks before using this rating.
def _get_impact_load(self):
"""
Calculates the impact load for a given member as defined in AREMA 15-7.3.2.3, 15-1.3.5 and AREMA 15-1.3.5b,
updates self.impact_load, returns nothing
Returns
-------
None
"""
if self.length < 80 * units("ft"):
self.impact_load = (
self.impact_ballast_factor
* (40 - ((3 * self.length**2) / (1600 * units("ft") ** 2)))
) / 100
else:
self.impact_load = (
16 + ((600 * units("ft")) / (self.length - 30 * units("ft")))
) / 100
def _multi_presence_impact_load(self):
"""
Calcuates the effect of impact load for a track based on the length of the span and the number of tracks loading
the member, based on AREMA Table 15-1-5.
Returns
-------
None
"""
# Per AREMA Table 15-1-5, multi-presence impact reduction uses span length
# (distance between supports), not member length. self.span_length is correct here.
if self.no_of_tracks > 2:
# AREMA Table 15-1-5 only covers 1-2 tracks explicitly.
# For >2 tracks, AREMA 15-1.3.5 requires the engineer to determine
# the governing loaded-track combination and impact manually.
# Set self.impact_load after constructing the object in that case.
print(
f"{PrintColors.FAIL}Error: >2 tracks — calculate impact load per AREMA 15-1.3.5 and set "
f"self.impact_load manually{PrintColors.ENDC}"
)
elif self.span_length < 175 * units.ft:
self.impact_load = self.impact_ballast_factor * 2
elif 175 * units.ft < self.span_length < 225 * units.ft:
self.impact_load = self.impact_ballast_factor * (
450 * units.ft - 2 * self.span_length
)
def _calculate_eccentricity(self):
"""
Uses the Super Elevation and eccentricity of the member to calculate and eccentricity force for a particular
member
# Note: e_TR (track eccentricity) is N/A for tangent track (no eccentricity) — pass 0.
# This function returns a dimensionless decimal fraction; callers must not multiply by 100.
Returns
-------
Eccentricity effect for inside rail as a percentage
"""
e_se = 8 * units("ft") * self.super_elevation / self.rail_spacing
e_cc = self.track_eccentricity + e_se
return ((2 * e_cc) / self.rail_spacing).to("dimensionless")
def _get_max_load_values(self, load_type):
element_loads = self.results_df[self.results_df["Load"] == load_type]
max_load_moment = max(pd.to_numeric(element_loads["Moment-y"]).abs()) * units(
"kip*ft"
)
max_load_shear = max(pd.to_numeric(element_loads["Shear-z"]).abs()) * units(
"kip"
)
return max_load_moment, max_load_shear
[docs]
def print_results(self):
print(f"{self.impact_load = :}")
print(f"{self.CF_force = :}")
print(f"{self.CF_percentage = :}\n\n")
print(f"{self.EC = :}\n\n")
print(f"{self.wind_height = :}")
print(f"{self.wind_load_live = :}\n\n") # Not actually being used
print(f"{self.max_moment_dead_load = :}")
print(f"{self.max_moment_live_load = :}")
print(f"{self.max_moment_rocking = :}\n\n")
print(f"{self.max_shear_dead_load = :}")
print(f"{self.max_shear_live_load = :}")
print(f"{self.max_shear_rocking = :}\n\n")
# Print Original Impact Values
print(f"{self.impact_moment = :}")
print(f"{self.impact_shear = :}")
print(f"{self.impact_reduction_factor = :}\n\n")
# Print reduced Impact
print(f"{self.M_reduced_impact = :}")
print(f"{self.V_reduced_impact = :}\n\n")
# Print Centrifugal Values
print(f"{self.M_centrifugal = :}")
print(f"{self.V_centrifugal = :}\n\n")
# Print Superelevation Values
print(f"{self.M_super_elevaion = :}")
print(f"{self.M_super_elevaion = :}\n\n")
# Print Wind Values
print(f"{self.M_wind = :}")
print(f"{self.V_wind = :}\n\n")
# Print Load Factor Values
print(f"{self.M_LF = :}")
print(f"{self.V_LF = :}\n\n")
# Print Allowable Stress
print(f"{self.allowable_stress = :}")
print(f"{self.F_bc = :}\n\n")
# Print Allowable Stress
print(f"{self.allowable_bending_stress = :}")
print(f"{self.allowable_shear_stress = :}\n\n")
# Print E80 Load rating
print(f"{self.E80_M = :}")
print(f"{self.E80_V = :}\n\n")
# Print Max E80 Load rating
print(f"{self.k_max = :}")
print(f"{self.k_bt = :}")
print(f"{self.k_bc = :}")
print(f"{self.k_b = :}")
print(f"{self.k_v = :}\n\n")
print(f"{self.max_M = :}")
print(f"{self.max_V = :}")