Update app.py
Browse files
app.py
CHANGED
|
@@ -1,129 +1,286 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import math
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# ---------------------------
|
| 9 |
-
#
|
| 10 |
-
# Cleaned, self-contained, ready for deployment to Hugging Face Spaces
|
| 11 |
-
# Requirements: streamlit, pandas, numpy, plotly
|
| 12 |
# ---------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
layout="wide",
|
| 18 |
-
initial_sidebar_state="expanded",
|
| 19 |
-
)
|
| 20 |
-
|
| 21 |
-
# --- Simple styling ---
|
| 22 |
-
st.markdown(
|
| 23 |
-
"""
|
| 24 |
-
<style>
|
| 25 |
-
.main-header { font-size: 2.2rem; font-weight: 700; color: #0b5fff; }
|
| 26 |
-
.calc-section { background-color: #f8f9fa; padding: 0.8rem; border-radius: 8px; }
|
| 27 |
-
.result-box { background-color: #e8f5e8; padding: 0.8rem; border-radius: 8px; }
|
| 28 |
-
.warning-box { background-color: #fff3cd; padding: 0.8rem; border-radius: 8px; }
|
| 29 |
-
@media (max-width: 768px) { .main-header { font-size: 1.6rem; } }
|
| 30 |
-
</style>
|
| 31 |
-
""",
|
| 32 |
-
unsafe_allow_html=True,
|
| 33 |
-
)
|
| 34 |
|
| 35 |
# ---------------------------
|
| 36 |
-
#
|
|
|
|
|
|
|
|
|
|
| 37 |
# ---------------------------
|
| 38 |
-
|
|
|
|
| 39 |
"Carbon Steel": {
|
| 40 |
-
"SA-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
"allowable_stress": {
|
| 42 |
-
"USC": {-20:
|
| 43 |
-
"SI": {-29:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
},
|
| 45 |
"carbon_equivalent": 0.30,
|
| 46 |
"material_group": "P1",
|
|
|
|
| 47 |
},
|
| 48 |
-
"SA-516
|
| 49 |
"allowable_stress": {
|
| 50 |
-
"USC": {-20: 17500, 100: 17500, 200: 17500, 300: 17500
|
| 51 |
-
"SI": {-29: 121, 38: 121, 93: 121, 149: 121
|
| 52 |
},
|
| 53 |
"carbon_equivalent": 0.31,
|
| 54 |
"material_group": "P1",
|
|
|
|
| 55 |
},
|
| 56 |
},
|
| 57 |
"Stainless Steel": {
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
"allowable_stress": {
|
| 60 |
-
"USC": {-20:
|
| 61 |
-
"SI": {-29:
|
| 62 |
},
|
| 63 |
"carbon_equivalent": 0.08,
|
| 64 |
"material_group": "P8",
|
|
|
|
| 65 |
},
|
| 66 |
-
"SA-
|
| 67 |
"allowable_stress": {
|
| 68 |
-
"USC": {-20:
|
| 69 |
-
"SI": {-29:
|
| 70 |
},
|
| 71 |
"carbon_equivalent": 0.08,
|
| 72 |
"material_group": "P8",
|
|
|
|
| 73 |
},
|
| 74 |
},
|
|
|
|
| 75 |
}
|
| 76 |
|
| 77 |
# ---------------------------
|
| 78 |
# Helper functions
|
| 79 |
# ---------------------------
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
try:
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
| 98 |
if from_unit == to_unit:
|
| 99 |
-
return
|
| 100 |
-
if from_unit == "C" and to_unit == "F":
|
| 101 |
-
return (temp * 9.0 / 5.0) + 32.0
|
| 102 |
if from_unit == "F" and to_unit == "C":
|
| 103 |
-
return (
|
| 104 |
-
|
|
|
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
-
def
|
| 108 |
-
"""
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
formula_used = "t = PR/(SE - 0.6P)"
|
| 113 |
-
condition = f"P ≤ 0.385SE: {P:.3f} ≤ {
|
| 114 |
else:
|
| 115 |
-
|
| 116 |
formula_used = "t = PR/(SE + 0.4P)"
|
| 117 |
-
condition = f"P > 0.385SE: {P:.3f} > {
|
| 118 |
|
|
|
|
| 119 |
t_long = (P * R) / (2 * S * E + 0.4 * P)
|
| 120 |
-
|
| 121 |
-
|
|
|
|
| 122 |
|
| 123 |
return {
|
| 124 |
-
"circumferential_thickness": float(
|
| 125 |
"longitudinal_thickness": float(t_long),
|
| 126 |
-
"required_thickness": float(
|
| 127 |
"governing_case": governing,
|
| 128 |
"formula_used": formula_used,
|
| 129 |
"condition_check": condition,
|
|
@@ -131,139 +288,239 @@ def calculate_shell_thickness(P, R, S, E):
|
|
| 131 |
}
|
| 132 |
|
| 133 |
|
| 134 |
-
def calculate_head_thickness(P, D, S, E, head_type="ellipsoidal"):
|
| 135 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
if head_type == "ellipsoidal":
|
| 137 |
-
K = 1.0
|
| 138 |
t = (P * D * K) / (2 * S * E - 0.2 * P)
|
|
|
|
|
|
|
| 139 |
clause = "UG-32(d), Appendix 1-4(c)"
|
| 140 |
-
formula = "t = PDK/(2SE - 0.2P)"
|
| 141 |
elif head_type == "torispherical":
|
|
|
|
| 142 |
L = D
|
| 143 |
r = 0.1 * D
|
| 144 |
M = 0.25 * (3.0 + math.sqrt(L / r))
|
| 145 |
t = (P * L * M) / (2 * S * E - 0.2 * P)
|
|
|
|
|
|
|
| 146 |
clause = "UG-32(e), Appendix 1-4(d)"
|
| 147 |
-
formula = f"t = PLM/(2SE - 0.2P), M={M:.3f}"
|
| 148 |
elif head_type == "hemispherical":
|
| 149 |
L = D / 2.0
|
| 150 |
t = (P * L) / (2 * S * E - 0.2 * P)
|
|
|
|
|
|
|
| 151 |
clause = "UG-32(f), Appendix 1-4(e)"
|
| 152 |
-
formula = "t = PL/(2SE - 0.2P)"
|
| 153 |
else:
|
| 154 |
-
raise ValueError("Unsupported head type")
|
| 155 |
|
| 156 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
|
| 159 |
-
def calculate_nozzle_reinforcement(
|
| 160 |
-
"""
|
| 161 |
-
|
| 162 |
-
A_required =
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
A_nozzle = t_nozzle * height_limit
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
return {
|
| 174 |
"area_required": float(A_required),
|
| 175 |
"area_shell": float(A_shell),
|
| 176 |
"area_nozzle": float(A_nozzle),
|
| 177 |
"area_pad": float(A_pad),
|
| 178 |
-
"area_welds": float(A_welds),
|
| 179 |
"area_available_total": float(A_available),
|
| 180 |
-
"reinforcement_adequate":
|
| 181 |
-
"safety_factor": float(
|
| 182 |
-
"
|
|
|
|
| 183 |
}
|
| 184 |
|
| 185 |
|
| 186 |
-
def determine_pwht_requirements(
|
| 187 |
-
"""
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
info = MATERIALS[material_category][material_grade]
|
| 194 |
-
mg = info["material_group"]
|
| 195 |
-
ce = info["carbon_equivalent"]
|
| 196 |
|
| 197 |
pwht_required = False
|
| 198 |
reasons = []
|
| 199 |
temp_range = ""
|
| 200 |
hold_time = ""
|
| 201 |
|
| 202 |
-
if
|
| 203 |
-
if
|
| 204 |
pwht_required = True
|
| 205 |
-
reasons.append("Thickness > 1.25 inches (UCS-56)")
|
| 206 |
-
temp_range = "1100°F - 1200°F"
|
| 207 |
-
hold_time = f"Minimum {max(1,
|
| 208 |
-
if
|
| 209 |
pwht_required = True
|
| 210 |
-
reasons.append("Carbon equivalent > 0.35% (UCS-56)")
|
| 211 |
-
elif
|
| 212 |
-
reasons.append("Stainless steel: PWHT generally not required
|
|
|
|
|
|
|
| 213 |
|
| 214 |
if not pwht_required:
|
| 215 |
-
reasons.append("Below thickness
|
| 216 |
|
| 217 |
return {"pwht_required": pwht_required, "reasons": reasons, "temperature_range": temp_range, "hold_time": hold_time}
|
| 218 |
|
| 219 |
|
| 220 |
-
def
|
| 221 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
if units == "SI":
|
| 223 |
-
|
| 224 |
-
thickness_inch = thickness_in / 25.4
|
| 225 |
else:
|
| 226 |
-
|
| 227 |
-
thickness_inch = thickness_in
|
| 228 |
-
|
| 229 |
-
info = MATERIALS[material_category][material_grade]
|
| 230 |
-
mg = info["material_group"]
|
| 231 |
-
|
| 232 |
-
exemption_map_P1 = {0.5: 60, 1.0: 30, 2.0: 0, 4.0: -20}
|
| 233 |
-
exemption_map_P8 = {0.5: -325, 1.0: -325, 2.0: -325, 4.0: -325}
|
| 234 |
-
|
| 235 |
-
if mg == "P1":
|
| 236 |
-
xs = sorted(exemption_map_P1.keys())
|
| 237 |
-
ys = [exemption_map_P1[x] for x in xs]
|
| 238 |
-
if thickness_inch <= xs[0]:
|
| 239 |
-
ex_temp = ys[0]
|
| 240 |
-
elif thickness_inch >= xs[-1]:
|
| 241 |
-
ex_temp = ys[-1]
|
| 242 |
-
else:
|
| 243 |
-
ex_temp = float(np.interp(thickness_inch, xs, ys))
|
| 244 |
-
else:
|
| 245 |
-
ex_temp = exemption_map_P8[0.5]
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
return {
|
| 251 |
-
"impact_test_required": impact_required,
|
| 252 |
-
"
|
| 253 |
-
"
|
| 254 |
-
"
|
|
|
|
|
|
|
| 255 |
}
|
| 256 |
|
| 257 |
|
| 258 |
-
def
|
| 259 |
-
"""
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
if component_type == "shell":
|
| 262 |
-
# P = SE t / (R + 0.6 t)
|
| 263 |
P = (S * E * t) / (R_or_D + 0.6 * t)
|
| 264 |
-
formula = "P =
|
| 265 |
else:
|
| 266 |
-
# approximate head MAWP
|
|
|
|
|
|
|
| 267 |
if head_type == "ellipsoidal":
|
| 268 |
K = 1.0
|
| 269 |
P = (2 * S * E * t * K) / (R_or_D + 0.2 * t)
|
|
@@ -272,6 +529,7 @@ def calculate_mawp(t, R_or_D, S, E, component_type="shell", head_type="ellipsoid
|
|
| 272 |
P = (2 * S * E * t) / (R_or_D + 0.4 * t)
|
| 273 |
formula = "P = 2SEt/(R + 0.4t)"
|
| 274 |
else:
|
|
|
|
| 275 |
P = (2 * S * E * t) / (R_or_D + 0.2 * t)
|
| 276 |
formula = "P = 2SEt/(D + 0.2t)"
|
| 277 |
|
|
@@ -282,150 +540,450 @@ def calculate_mawp(t, R_or_D, S, E, component_type="shell", head_type="ellipsoid
|
|
| 282 |
# Streamlit UI
|
| 283 |
# ---------------------------
|
| 284 |
|
| 285 |
-
def
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
else:
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
-
st.sidebar.subheader("Design Conditions")
|
| 305 |
-
design_pressure = st.sidebar.number_input(f"Design Pressure ({pressure_unit})", value=150.0)
|
| 306 |
-
design_temperature = st.sidebar.number_input(f"Design Temperature ({temp_unit})", value=200.0)
|
| 307 |
-
corrosion_allowance = st.sidebar.number_input(f"Corrosion Allowance ({length_unit})", value=0.125)
|
| 308 |
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
joint_eff = st.sidebar.selectbox("Joint Efficiency (E)", [1.0, 0.95, 0.9, 0.85], index=0)
|
| 313 |
-
inside_diameter = st.sidebar.number_input(f"Inside Diameter ({length_unit})", value=60.0)
|
| 314 |
-
head_type = st.sidebar.selectbox("Head Type", ["ellipsoidal", "torispherical", "hemispherical"])
|
| 315 |
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
with tab1:
|
| 323 |
st.header("Shell Thickness (UG-27)")
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
|
| 331 |
with tab2:
|
| 332 |
st.header("Head Thickness (UG-32)")
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
with tab3:
|
| 339 |
-
st.header("Nozzle Reinforcement (UG-37)")
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
else:
|
| 355 |
-
st.
|
| 356 |
|
| 357 |
with tab4:
|
| 358 |
-
st.header("PWHT & Impact")
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
|
|
|
|
|
|
| 366 |
st.write("Reasons:")
|
| 367 |
-
for r in pwht[
|
| 368 |
-
st.write(
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
else:
|
| 371 |
-
st.
|
| 372 |
-
|
| 373 |
-
st.subheader("Impact Test")
|
| 374 |
-
st.write("Design temperature (°F):", f"{impact['design_temperature_f']:.1f}")
|
| 375 |
-
st.write("Exemption temperature (°F):", f"{impact['exemption_temperature_f']:.1f}")
|
| 376 |
-
if impact['impact_test_required']:
|
| 377 |
-
st.error("Impact test required — Charpy V-notch")
|
| 378 |
-
st.write("Test temp (°F):", f"{impact['test_temperature_f']:.1f}")
|
| 379 |
-
else:
|
| 380 |
-
st.success("Impact test not required")
|
| 381 |
|
| 382 |
with tab5:
|
| 383 |
-
st.header("Summary
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
"
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
"
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
f"{
|
| 413 |
-
f"{
|
| 414 |
-
f"{
|
| 415 |
-
|
| 416 |
-
f"{
|
| 417 |
-
f"{
|
| 418 |
-
f"{
|
| 419 |
-
f"{
|
| 420 |
-
f"{
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
|
|
|
|
|
|
|
|
|
|
| 429 |
|
| 430 |
if __name__ == "__main__":
|
| 431 |
main()
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
ASME Section VIII — Preliminary Pressure Vessel Calculator (Streamlit)
|
| 4 |
+
|
| 5 |
+
Single-file Streamlit app with:
|
| 6 |
+
- USC / SI units
|
| 7 |
+
- Material database (selectable)
|
| 8 |
+
- UG-27 shell thickness (circumferential & longitudinal checks)
|
| 9 |
+
- UG-32 head thickness (ellipsoidal / torispherical / hemispherical)
|
| 10 |
+
- Simplified UG-37-style nozzle reinforcement area method (approx)
|
| 11 |
+
- PWHT recommendation (simplified, UCS-56 style)
|
| 12 |
+
- Impact test (Design MDMT vs Rated MDMT) approximation (UCS-66 style approximation)
|
| 13 |
+
- MAWP calculation from thickness
|
| 14 |
+
- CSV export and optional PDF export (reportlab)
|
| 15 |
+
|
| 16 |
+
ASSUMPTION: This is a preliminary tool. Approximations are labeled and conservative by design.
|
| 17 |
+
APPROXIMATION: MDMT and some reinforcement area calculations are simplified for demo/testing.
|
| 18 |
+
|
| 19 |
+
Requirements: streamlit, pandas, numpy, plotly (optional), reportlab (optional)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
from typing import Dict, Any, Optional, Tuple
|
| 24 |
import math
|
| 25 |
+
import datetime
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import pandas as pd
|
| 29 |
+
import streamlit as st
|
| 30 |
+
|
| 31 |
+
# Module metadata
|
| 32 |
+
__version__ = "0.1.0"
|
| 33 |
|
| 34 |
# ---------------------------
|
| 35 |
+
# Constants
|
|
|
|
|
|
|
| 36 |
# ---------------------------
|
| 37 |
+
ATM_PRESSURE_PSI = 14.7 # used only for context
|
| 38 |
+
PIPE_THICKNESS_REDUCTION = 0.125 # 12.5% reduction for pipe grades
|
| 39 |
+
DEFAULT_CORROSION_ALLOWANCE_IN = 0.125
|
| 40 |
+
SUPPORTED_ASME_EDITIONS = ["2019", "2021", "custom"]
|
| 41 |
|
| 42 |
+
# Units formatting
|
| 43 |
+
INCH_DECIMALS = 4
|
| 44 |
+
MM_DECIMALS = 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
# ---------------------------
|
| 47 |
+
# MATERIALS - simplified ASME Section II style tables (representative)
|
| 48 |
+
# Each material maps to a dict: { 'allowable_stress': {units: {temp: stress, ...}}, 'material_group': str, 'carbon_equivalent': float }
|
| 49 |
+
# Temperatures provided in °F for USC and °C for SI. Values are representative and for demo - not exhaustive.
|
| 50 |
+
# TODO: Replace with full ASME Section II tables keyed by edition.
|
| 51 |
# ---------------------------
|
| 52 |
+
|
| 53 |
+
MATERIALS: Dict[str, Dict[str, Dict[str, Any]]] = {
|
| 54 |
"Carbon Steel": {
|
| 55 |
+
"SA-106B": {
|
| 56 |
+
"allowable_stress": {
|
| 57 |
+
"USC": { -20: 20000, 100: 20000, 200: 19000, 300: 17000 }, # psi
|
| 58 |
+
"SI": { -29: 138, 38: 138, 93: 131, 149: 117 }, # MPa
|
| 59 |
+
},
|
| 60 |
+
"carbon_equivalent": 0.35,
|
| 61 |
+
"material_group": "P1",
|
| 62 |
+
"is_pipe_grade": True,
|
| 63 |
+
},
|
| 64 |
+
"SA-53B-ERW": {
|
| 65 |
"allowable_stress": {
|
| 66 |
+
"USC": { -20: 20000, 100: 20000, 200: 19000, 300: 17000 },
|
| 67 |
+
"SI": { -29: 138, 38: 138, 93: 131, 149: 117 },
|
| 68 |
+
},
|
| 69 |
+
"carbon_equivalent": 0.34,
|
| 70 |
+
"material_group": "P1",
|
| 71 |
+
"is_pipe_grade": True,
|
| 72 |
+
},
|
| 73 |
+
"SA-516-60": {
|
| 74 |
+
"allowable_stress": {
|
| 75 |
+
"USC": { -20: 15000, 100: 15000, 200: 15000, 300: 15000 },
|
| 76 |
+
"SI": { -29: 103, 38: 103, 93: 103, 149: 103 },
|
| 77 |
},
|
| 78 |
"carbon_equivalent": 0.30,
|
| 79 |
"material_group": "P1",
|
| 80 |
+
"is_pipe_grade": False,
|
| 81 |
},
|
| 82 |
+
"SA-516-70": {
|
| 83 |
"allowable_stress": {
|
| 84 |
+
"USC": { -20: 17500, 100: 17500, 200: 17500, 300: 17500 },
|
| 85 |
+
"SI": { -29: 121, 38: 121, 93: 121, 149: 121 },
|
| 86 |
},
|
| 87 |
"carbon_equivalent": 0.31,
|
| 88 |
"material_group": "P1",
|
| 89 |
+
"is_pipe_grade": False,
|
| 90 |
},
|
| 91 |
},
|
| 92 |
"Stainless Steel": {
|
| 93 |
+
# plate/tube
|
| 94 |
+
"SA-240-304": {
|
| 95 |
+
"allowable_stress": {
|
| 96 |
+
"USC": { -20: 18800, 100: 18800, 200: 16200 },
|
| 97 |
+
"SI": { -29: 130, 38: 130, 93: 112 },
|
| 98 |
+
},
|
| 99 |
+
"carbon_equivalent": 0.08,
|
| 100 |
+
"material_group": "P8",
|
| 101 |
+
"is_pipe_grade": False,
|
| 102 |
+
},
|
| 103 |
+
"SA-240-316": {
|
| 104 |
+
"allowable_stress": {
|
| 105 |
+
"USC": { -20: 18800, 100: 18800, 200: 16600 },
|
| 106 |
+
"SI": { -29: 130, 38: 130, 93: 114 },
|
| 107 |
+
},
|
| 108 |
+
"carbon_equivalent": 0.08,
|
| 109 |
+
"material_group": "P8",
|
| 110 |
+
"is_pipe_grade": False,
|
| 111 |
+
},
|
| 112 |
+
"SA-240-304L": {
|
| 113 |
+
"allowable_stress": {
|
| 114 |
+
"USC": { -20: 18000, 100: 18000, 200: 16000 },
|
| 115 |
+
"SI": { -29: 124, 38: 124, 93: 110 },
|
| 116 |
+
},
|
| 117 |
+
"carbon_equivalent": 0.08,
|
| 118 |
+
"material_group": "P8",
|
| 119 |
+
"is_pipe_grade": False,
|
| 120 |
+
},
|
| 121 |
+
"SA-240-316L": {
|
| 122 |
+
"allowable_stress": {
|
| 123 |
+
"USC": { -20: 18000, 100: 18000, 200: 16400 },
|
| 124 |
+
"SI": { -29: 124, 38: 124, 93: 112 },
|
| 125 |
+
},
|
| 126 |
+
"carbon_equivalent": 0.08,
|
| 127 |
+
"material_group": "P8",
|
| 128 |
+
"is_pipe_grade": False,
|
| 129 |
+
},
|
| 130 |
+
# pipe
|
| 131 |
+
"SA-312-304": {
|
| 132 |
+
"allowable_stress": {
|
| 133 |
+
"USC": { -20: 18800, 100: 18800, 200: 16200 },
|
| 134 |
+
"SI": { -29: 130, 38: 130, 93: 112 },
|
| 135 |
+
},
|
| 136 |
+
"carbon_equivalent": 0.08,
|
| 137 |
+
"material_group": "P8",
|
| 138 |
+
"is_pipe_grade": True,
|
| 139 |
+
},
|
| 140 |
+
"SA-312-316": {
|
| 141 |
+
"allowable_stress": {
|
| 142 |
+
"USC": { -20: 18800, 100: 18800, 200: 16600 },
|
| 143 |
+
"SI": { -29: 130, 38: 130, 93: 114 },
|
| 144 |
+
},
|
| 145 |
+
"carbon_equivalent": 0.08,
|
| 146 |
+
"material_group": "P8",
|
| 147 |
+
"is_pipe_grade": True,
|
| 148 |
+
},
|
| 149 |
+
"SA-312-304L": {
|
| 150 |
"allowable_stress": {
|
| 151 |
+
"USC": { -20: 18000, 100: 18000, 200: 16000 },
|
| 152 |
+
"SI": { -29: 124, 38: 124, 93: 110 },
|
| 153 |
},
|
| 154 |
"carbon_equivalent": 0.08,
|
| 155 |
"material_group": "P8",
|
| 156 |
+
"is_pipe_grade": True,
|
| 157 |
},
|
| 158 |
+
"SA-312-316L": {
|
| 159 |
"allowable_stress": {
|
| 160 |
+
"USC": { -20: 18000, 100: 18000, 200: 16400 },
|
| 161 |
+
"SI": { -29: 124, 38: 124, 93: 112 },
|
| 162 |
},
|
| 163 |
"carbon_equivalent": 0.08,
|
| 164 |
"material_group": "P8",
|
| 165 |
+
"is_pipe_grade": True,
|
| 166 |
},
|
| 167 |
},
|
| 168 |
+
# User Defined handled separately
|
| 169 |
}
|
| 170 |
|
| 171 |
# ---------------------------
|
| 172 |
# Helper functions
|
| 173 |
# ---------------------------
|
| 174 |
|
| 175 |
+
|
| 176 |
+
def _interpolate_allowable(stress_table: Dict[float, float], temperature: float) -> float:
|
| 177 |
+
"""Linear interpolation for stress vs temperature table. Expects sorted keys possible unsorted."""
|
| 178 |
+
temps = sorted(stress_table.keys())
|
| 179 |
+
stresses = [stress_table[t] for t in temps]
|
| 180 |
+
if temperature <= temps[0]:
|
| 181 |
+
return float(stresses[0])
|
| 182 |
+
if temperature >= temps[-1]:
|
| 183 |
+
return float(stresses[-1])
|
| 184 |
+
return float(np.interp(temperature, temps, stresses))
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def get_allowable_stress(material_category: str, material_grade: str, design_temperature: float, units: str) -> Tuple[float, Optional[str]]:
|
| 188 |
+
"""
|
| 189 |
+
Returns (allowable_stress, warning_msg_or_none)
|
| 190 |
+
units: "USC" or "SI"
|
| 191 |
+
ASSUMPTION: MATERIALS table is representative. Replace with full ASME II table when available.
|
| 192 |
+
"""
|
| 193 |
+
if material_category == "User Defined":
|
| 194 |
+
# Should be handled elsewhere; return sentinel
|
| 195 |
+
raise ValueError("User Defined materials should provide allowable stress explicitly.")
|
| 196 |
try:
|
| 197 |
+
mat_info = MATERIALS[material_category][material_grade]
|
| 198 |
+
except KeyError:
|
| 199 |
+
raise ValueError(f"Material grade {material_grade} not found under category {material_category}")
|
| 200 |
+
|
| 201 |
+
table = mat_info["allowable_stress"].get(units)
|
| 202 |
+
if table is None:
|
| 203 |
+
raise ValueError(f"No allowable stress data for units '{units}' for material {material_grade}")
|
| 204 |
+
temps = sorted(table.keys())
|
| 205 |
+
wmsg = None
|
| 206 |
+
if design_temperature < temps[0] or design_temperature > temps[-1]:
|
| 207 |
+
wmsg = f"Design temperature {design_temperature} outside table range [{temps[0]}, {temps[-1]}]. Using nearest-end value."
|
| 208 |
+
S = _interpolate_allowable(table, design_temperature)
|
| 209 |
+
return S, wmsg
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def convert_temp(value: float, from_unit: str, to_unit: str) -> float:
|
| 213 |
+
"""Convert temperature between 'F' and 'C'."""
|
| 214 |
if from_unit == to_unit:
|
| 215 |
+
return value
|
|
|
|
|
|
|
| 216 |
if from_unit == "F" and to_unit == "C":
|
| 217 |
+
return (value - 32.0) * 5.0 / 9.0
|
| 218 |
+
if from_unit == "C" and to_unit == "F":
|
| 219 |
+
return (value * 9.0 / 5.0) + 32.0
|
| 220 |
+
return value
|
| 221 |
|
| 222 |
|
| 223 |
+
def format_value(value: float, units: str, is_length: bool = False) -> str:
|
| 224 |
+
"""Format numeric values with sensible precision per unit."""
|
| 225 |
+
if is_length:
|
| 226 |
+
if units in ("in", "psi", "USC"): # treat as inches for length when USC
|
| 227 |
+
return f"{value:.{INCH_DECIMALS}f}"
|
| 228 |
+
else:
|
| 229 |
+
return f"{value:.{MM_DECIMALS}f}"
|
| 230 |
+
# default generic formatting
|
| 231 |
+
if abs(value) >= 1000:
|
| 232 |
+
return f"{value:,.0f}"
|
| 233 |
+
if abs(value) >= 1:
|
| 234 |
+
return f"{value:.3g}"
|
| 235 |
+
return f"{value:.4g}"
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ---------------------------
|
| 239 |
+
# Calculation functions (pure)
|
| 240 |
+
# ---------------------------
|
| 241 |
+
|
| 242 |
+
def calculate_shell_thickness(P: float, R: float, S: float, E: float) -> Dict[str, Any]:
|
| 243 |
+
"""
|
| 244 |
+
UG-27 circumferential formula (simplified).
|
| 245 |
+
P: design pressure (same units as S)
|
| 246 |
+
R: inside radius
|
| 247 |
+
S: allowable stress
|
| 248 |
+
E: joint efficiency (0 < E <= 1)
|
| 249 |
+
Returns a dict with required thicknesses and metadata.
|
| 250 |
+
"""
|
| 251 |
+
# Validate
|
| 252 |
+
if P < 0:
|
| 253 |
+
raise ValueError("Design pressure must be >= 0")
|
| 254 |
+
if R <= 0:
|
| 255 |
+
raise ValueError("Radius must be positive")
|
| 256 |
+
if S <= 0:
|
| 257 |
+
raise ValueError("Allowable stress must be positive")
|
| 258 |
+
if not (0 < E <= 1):
|
| 259 |
+
raise ValueError("Joint efficiency E must be in (0, 1]")
|
| 260 |
+
|
| 261 |
+
# UG-27 circumferential:
|
| 262 |
+
# if P <= 0.385 * S * E: t = PR/(SE - 0.6P)
|
| 263 |
+
# else: t = PR/(SE + 0.4P)
|
| 264 |
+
cond_val = 0.385 * S * E
|
| 265 |
+
if P <= cond_val:
|
| 266 |
+
t_circ = (P * R) / (S * E - 0.6 * P)
|
| 267 |
formula_used = "t = PR/(SE - 0.6P)"
|
| 268 |
+
condition = f"P ≤ 0.385SE: {P:.3f} ≤ {cond_val:.3f}"
|
| 269 |
else:
|
| 270 |
+
t_circ = (P * R) / (S * E + 0.4 * P)
|
| 271 |
formula_used = "t = PR/(SE + 0.4P)"
|
| 272 |
+
condition = f"P > 0.385SE: {P:.3f} > {cond_val:.3f}"
|
| 273 |
|
| 274 |
+
# longitudinal t = PR / (2SE + 0.4P)
|
| 275 |
t_long = (P * R) / (2 * S * E + 0.4 * P)
|
| 276 |
+
|
| 277 |
+
required = max(t_circ, t_long)
|
| 278 |
+
governing = "Circumferential" if t_circ >= t_long else "Longitudinal"
|
| 279 |
|
| 280 |
return {
|
| 281 |
+
"circumferential_thickness": float(t_circ),
|
| 282 |
"longitudinal_thickness": float(t_long),
|
| 283 |
+
"required_thickness": float(required),
|
| 284 |
"governing_case": governing,
|
| 285 |
"formula_used": formula_used,
|
| 286 |
"condition_check": condition,
|
|
|
|
| 288 |
}
|
| 289 |
|
| 290 |
|
| 291 |
+
def calculate_head_thickness(P: float, D: float, S: float, E: float, head_type: str = "ellipsoidal") -> Dict[str, Any]:
|
| 292 |
+
"""
|
| 293 |
+
Return required head thickness per simplified UG-32 formulas.
|
| 294 |
+
P: design pressure
|
| 295 |
+
D: inside diameter
|
| 296 |
+
S: allowable stress
|
| 297 |
+
E: joint efficiency
|
| 298 |
+
head_type: 'ellipsoidal', 'torispherical', 'hemispherical'
|
| 299 |
+
"""
|
| 300 |
+
if P < 0:
|
| 301 |
+
raise ValueError("Design pressure must be >= 0")
|
| 302 |
+
if D <= 0:
|
| 303 |
+
raise ValueError("Diameter must be positive")
|
| 304 |
+
if S <= 0:
|
| 305 |
+
raise ValueError("Allowable stress must be positive")
|
| 306 |
+
if not (0 < E <= 1):
|
| 307 |
+
raise ValueError("Joint efficiency E must be in (0,1]")
|
| 308 |
+
|
| 309 |
if head_type == "ellipsoidal":
|
| 310 |
+
K = 1.0 # geometry factor
|
| 311 |
t = (P * D * K) / (2 * S * E - 0.2 * P)
|
| 312 |
+
formula = "t = P*D*K/(2SE - 0.2P)"
|
| 313 |
+
inter = {"K": K}
|
| 314 |
clause = "UG-32(d), Appendix 1-4(c)"
|
|
|
|
| 315 |
elif head_type == "torispherical":
|
| 316 |
+
# approximations for torispherical: crown radius L = D, knuckle r = 0.1D
|
| 317 |
L = D
|
| 318 |
r = 0.1 * D
|
| 319 |
M = 0.25 * (3.0 + math.sqrt(L / r))
|
| 320 |
t = (P * L * M) / (2 * S * E - 0.2 * P)
|
| 321 |
+
formula = f"t = P*L*M/(2SE - 0.2P), M={M:.3f}"
|
| 322 |
+
inter = {"L": L, "r": r, "M": M}
|
| 323 |
clause = "UG-32(e), Appendix 1-4(d)"
|
|
|
|
| 324 |
elif head_type == "hemispherical":
|
| 325 |
L = D / 2.0
|
| 326 |
t = (P * L) / (2 * S * E - 0.2 * P)
|
| 327 |
+
formula = "t = P*L/(2SE - 0.2P)"
|
| 328 |
+
inter = {"L": L}
|
| 329 |
clause = "UG-32(f), Appendix 1-4(e)"
|
|
|
|
| 330 |
else:
|
| 331 |
+
raise ValueError(f"Unsupported head type '{head_type}'")
|
| 332 |
|
| 333 |
+
return {
|
| 334 |
+
"required_thickness": float(t),
|
| 335 |
+
"head_type": head_type,
|
| 336 |
+
"formula_used": formula,
|
| 337 |
+
"intermediate_values": inter,
|
| 338 |
+
"asme_clause": clause,
|
| 339 |
+
}
|
| 340 |
|
| 341 |
|
| 342 |
+
def calculate_nozzle_reinforcement(d_opening: float, t_shell_required: float, t_nozzle: float, t_pad: float, pad_width: float, material_grade: Optional[str] = None) -> Dict[str, Any]:
|
| 343 |
+
"""
|
| 344 |
+
Simplified UG-37 area method approximation.
|
| 345 |
+
- A_required = d_opening * t_shell_required
|
| 346 |
+
- A_nozzle approximated as t_nozzle * height (height approximated)
|
| 347 |
+
- A_pad = t_pad * pad_width
|
| 348 |
+
- A_shell conservative assumed zero (unless pad provides)
|
| 349 |
+
ASSUMPTION: This is conservative and simplified; real UG-37 includes integration over reinforcement area and weld contributions.
|
| 350 |
+
"""
|
| 351 |
+
if d_opening <= 0 or t_shell_required <= 0 or t_nozzle <= 0:
|
| 352 |
+
raise ValueError("Nozzle dimensions must be positive")
|
| 353 |
+
|
| 354 |
+
A_required = d_opening * t_shell_required
|
| 355 |
+
|
| 356 |
+
# Approximate nozzle contribution: height limited to 2.5 * smaller thickness
|
| 357 |
+
height_limit = min(2.5 * t_shell_required, 2.5 * t_nozzle)
|
| 358 |
A_nozzle = t_nozzle * height_limit
|
| 359 |
+
|
| 360 |
+
# Pad contribution
|
| 361 |
+
A_pad = t_pad * pad_width if (t_pad and pad_width) else 0.0
|
| 362 |
+
|
| 363 |
+
# Shell area conservative: we count none (ASSUMPTION: conservative)
|
| 364 |
+
A_shell = 0.0
|
| 365 |
+
|
| 366 |
+
# Apply pipe grade thickness reduction for nozzle if material_grade indicates pipe
|
| 367 |
+
reduction_info = ""
|
| 368 |
+
if material_grade:
|
| 369 |
+
# find if given material is in data and pipe grade
|
| 370 |
+
for cat in MATERIALS:
|
| 371 |
+
mg = MATERIALS[cat].get(material_grade)
|
| 372 |
+
if mg:
|
| 373 |
+
if mg.get("is_pipe_grade"):
|
| 374 |
+
# reduce nozzle effective thickness for reinforcement calc
|
| 375 |
+
effective_nozzle_t = t_nozzle * (1.0 - PIPE_THICKNESS_REDUCTION)
|
| 376 |
+
# recalc A_nozzle conservatively with reduced t
|
| 377 |
+
height_limit2 = min(2.5 * t_shell_required, 2.5 * effective_nozzle_t)
|
| 378 |
+
A_nozzle = effective_nozzle_t * height_limit2
|
| 379 |
+
reduction_info = f"Applied {PIPE_THICKNESS_REDUCTION*100:.1f}% pipe thickness reduction to nozzle (effective nozzle thickness = {effective_nozzle_t:.4f})."
|
| 380 |
+
break
|
| 381 |
+
|
| 382 |
+
A_available = A_shell + A_nozzle + A_pad
|
| 383 |
+
adequate = A_available >= A_required
|
| 384 |
+
safety_factor = (A_available / A_required) if A_required > 0 else float("inf")
|
| 385 |
+
|
| 386 |
+
assumptions = [
|
| 387 |
+
"A_shell conservative assumed zero (no excess shell area counted).",
|
| 388 |
+
"Nozzle area approximated with a limited height = min(2.5*t_shell, 2.5*t_nozzle).",
|
| 389 |
+
"Pad area = pad_thickness * pad_width (rectangular assumption).",
|
| 390 |
+
"Real UG-37 requires geometric reinforcement calculations; this is a simplified conservative estimate.",
|
| 391 |
+
]
|
| 392 |
+
if reduction_info:
|
| 393 |
+
assumptions.append(reduction_info)
|
| 394 |
|
| 395 |
return {
|
| 396 |
"area_required": float(A_required),
|
| 397 |
"area_shell": float(A_shell),
|
| 398 |
"area_nozzle": float(A_nozzle),
|
| 399 |
"area_pad": float(A_pad),
|
|
|
|
| 400 |
"area_available_total": float(A_available),
|
| 401 |
+
"reinforcement_adequate": bool(adequate),
|
| 402 |
+
"safety_factor": float(safety_factor),
|
| 403 |
+
"assumptions": assumptions,
|
| 404 |
+
"asme_clause": "UG-37 (approx)",
|
| 405 |
}
|
| 406 |
|
| 407 |
|
| 408 |
+
def determine_pwht_requirements(material_group: str, thickness_in_in: float, carbon_equivalent: float) -> Dict[str, Any]:
|
| 409 |
+
"""
|
| 410 |
+
Very simplified PWHT logic following UCS-56 style rules:
|
| 411 |
+
- ASSUMPTION: PWHT recommended if thickness > 1.25 in for P1 group or CE > 0.35
|
| 412 |
+
"""
|
| 413 |
+
if thickness_in_in < 0:
|
| 414 |
+
raise ValueError("Thickness must be non-negative")
|
|
|
|
|
|
|
|
|
|
| 415 |
|
| 416 |
pwht_required = False
|
| 417 |
reasons = []
|
| 418 |
temp_range = ""
|
| 419 |
hold_time = ""
|
| 420 |
|
| 421 |
+
if material_group == "P1":
|
| 422 |
+
if thickness_in_in > 1.25:
|
| 423 |
pwht_required = True
|
| 424 |
+
reasons.append("Thickness > 1.25 inches (UCS-56 guidance).")
|
| 425 |
+
temp_range = "1100°F - 1200°F"
|
| 426 |
+
hold_time = f"Minimum {max(1, int(math.ceil(thickness_in_in)))} hours (approx)"
|
| 427 |
+
if carbon_equivalent > 0.35:
|
| 428 |
pwht_required = True
|
| 429 |
+
reasons.append("Carbon equivalent > 0.35% (UCS-56).")
|
| 430 |
+
elif material_group == "P8":
|
| 431 |
+
reasons.append("Stainless steel (P8): PWHT generally not required; follow spec.")
|
| 432 |
+
else:
|
| 433 |
+
reasons.append("Material group unknown: consult specification.")
|
| 434 |
|
| 435 |
if not pwht_required:
|
| 436 |
+
reasons.append("Below thickness/CE limits for mandatory PWHT (preliminary check).")
|
| 437 |
|
| 438 |
return {"pwht_required": pwht_required, "reasons": reasons, "temperature_range": temp_range, "hold_time": hold_time}
|
| 439 |
|
| 440 |
|
| 441 |
+
def _approximate_rated_mdmt(material_group: str, thickness_in_in: float) -> float:
|
| 442 |
+
"""
|
| 443 |
+
APPROXIMATION: Provide a conservative approximate rated MDMT based on material group and thickness.
|
| 444 |
+
This is NOT the full UCS-66 lookup. Use as a placeholder to compare with Design MDMT.
|
| 445 |
+
We define a simple table for P1 and P8:
|
| 446 |
+
- For P1 (carbon): thicker -> higher (warmer) rated MDMT threshold (conservative)
|
| 447 |
+
- For P8 (stainless): very low rated MDMT (stainless generally exempt)
|
| 448 |
+
Returns MDMT in °F.
|
| 449 |
+
"""
|
| 450 |
+
if material_group == "P8":
|
| 451 |
+
return -325.0 # effectively exempt in many cases (very low)
|
| 452 |
+
# P1 conservative regression-like mapping (values illustrative)
|
| 453 |
+
if thickness_in_in <= 0.5:
|
| 454 |
+
return 60.0
|
| 455 |
+
if thickness_in_in <= 1.0:
|
| 456 |
+
return 30.0
|
| 457 |
+
if thickness_in_in <= 2.0:
|
| 458 |
+
return 0.0
|
| 459 |
+
if thickness_in_in <= 4.0:
|
| 460 |
+
return -20.0
|
| 461 |
+
return -50.0
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def determine_impact_test_requirements(material_group: str, design_mdmt: float, thickness_in_in: float, units: str) -> Dict[str, Any]:
|
| 465 |
+
"""
|
| 466 |
+
Compare Design MDMT (user input) with approximate Rated MDMT.
|
| 467 |
+
Returns whether impact test required and test temp (Design MDMT - 30°F or -17°C).
|
| 468 |
+
Units: 'USC' or 'SI' for comparisons and for returned values.
|
| 469 |
+
"""
|
| 470 |
+
if thickness_in_in < 0:
|
| 471 |
+
raise ValueError("Thickness must be non-negative")
|
| 472 |
+
if units not in ("USC", "SI"):
|
| 473 |
+
raise ValueError("units must be 'USC' or 'SI'")
|
| 474 |
+
|
| 475 |
+
# Work in °F internally for rated_mdmt approximation
|
| 476 |
if units == "SI":
|
| 477 |
+
design_mdmt_f = convert_temp(design_mdmt, "C", "F")
|
|
|
|
| 478 |
else:
|
| 479 |
+
design_mdmt_f = design_mdmt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
+
rated_mdmt_f = _approximate_rated_mdmt(material_group, thickness_in_in)
|
| 482 |
+
|
| 483 |
+
# Coincident ratio: ASSUMPTION - we use a simple rule: if design_temp within 30°F of rated, may be allowed by coincident rules
|
| 484 |
+
coincident_info = "Coincident ratio not fully implemented; conservative compare used. See UCS-66 for full logic."
|
| 485 |
+
impact_required = design_mdmt_f > rated_mdmt_f # if design is colder (lower), fine; if warmer (higher) -> required
|
| 486 |
+
# test temperature suggestion
|
| 487 |
+
if units == "SI":
|
| 488 |
+
test_temp = convert_temp(design_mdmt_f - 30.0, "F", "C")
|
| 489 |
+
else:
|
| 490 |
+
test_temp = design_mdmt_f - 30.0
|
| 491 |
|
| 492 |
return {
|
| 493 |
+
"impact_test_required": bool(impact_required),
|
| 494 |
+
"rated_mdmt": rated_mdmt_f if units == "USC" else convert_temp(rated_mdmt_f, "F", "C"),
|
| 495 |
+
"design_mdmt": design_mdmt,
|
| 496 |
+
"exemption_coincident_ratio_info": coincident_info,
|
| 497 |
+
"test_temperature": test_temp,
|
| 498 |
+
"notes": "APPROXIMATION: rated MDMT is an estimate - use UCS-66 tables for precise determination.",
|
| 499 |
}
|
| 500 |
|
| 501 |
|
| 502 |
+
def calculate_mawp_from_thickness(t: float, R_or_D: float, S: float, E: float, component_type: str = "shell", head_type: Optional[str] = None) -> Dict[str, Any]:
|
| 503 |
+
"""
|
| 504 |
+
Inverse of thickness formulas to compute MAWP.
|
| 505 |
+
If component_type == 'shell', use P = (SE t) / (R + 0.6 t)
|
| 506 |
+
For heads, approximate using ellipsoidal style.
|
| 507 |
+
"""
|
| 508 |
+
if t <= 0:
|
| 509 |
+
raise ValueError("Thickness must be positive")
|
| 510 |
+
if R_or_D <= 0:
|
| 511 |
+
raise ValueError("R_or_D must be positive")
|
| 512 |
+
if S <= 0:
|
| 513 |
+
raise ValueError("Allowable stress must be positive")
|
| 514 |
+
if not (0 < E <= 1):
|
| 515 |
+
raise ValueError("Joint efficiency E must be in (0,1]")
|
| 516 |
+
|
| 517 |
if component_type == "shell":
|
|
|
|
| 518 |
P = (S * E * t) / (R_or_D + 0.6 * t)
|
| 519 |
+
formula = "P = SE*t/(R + 0.6t)"
|
| 520 |
else:
|
| 521 |
+
# approximate head MAWP (ellipsoidal form)
|
| 522 |
+
if head_type is None:
|
| 523 |
+
head_type = "ellipsoidal"
|
| 524 |
if head_type == "ellipsoidal":
|
| 525 |
K = 1.0
|
| 526 |
P = (2 * S * E * t * K) / (R_or_D + 0.2 * t)
|
|
|
|
| 529 |
P = (2 * S * E * t) / (R_or_D + 0.4 * t)
|
| 530 |
formula = "P = 2SEt/(R + 0.4t)"
|
| 531 |
else:
|
| 532 |
+
# fallback
|
| 533 |
P = (2 * S * E * t) / (R_or_D + 0.2 * t)
|
| 534 |
formula = "P = 2SEt/(D + 0.2t)"
|
| 535 |
|
|
|
|
| 540 |
# Streamlit UI
|
| 541 |
# ---------------------------
|
| 542 |
|
| 543 |
+
def init_session_state_defaults():
|
| 544 |
+
"""Initialize session defaults for inputs."""
|
| 545 |
+
defaults = {
|
| 546 |
+
"unit_system": "USC",
|
| 547 |
+
"design_pressure": 150.0,
|
| 548 |
+
"design_temperature": 200.0,
|
| 549 |
+
"design_mdmt": -20.0,
|
| 550 |
+
"corrosion_allowance": DEFAULT_CORROSION_ALLOWANCE_IN,
|
| 551 |
+
"inside_diameter": 60.0,
|
| 552 |
+
"head_type": "ellipsoidal",
|
| 553 |
+
"joint_efficiency": 1.0,
|
| 554 |
+
"material_category": "Carbon Steel",
|
| 555 |
+
"material_grade": "SA-516-70",
|
| 556 |
+
"user_defined_allowable": 15000.0,
|
| 557 |
+
"nozzle_opening_diameter": 6.0,
|
| 558 |
+
"nozzle_wall_thickness": 0.5,
|
| 559 |
+
"use_reinforcing_pad": False,
|
| 560 |
+
"pad_thickness": 0.25,
|
| 561 |
+
"pad_width": 8.0,
|
| 562 |
+
"calc_atm_shell": False,
|
| 563 |
+
"asme_edition": SUPPORTED_ASME_EDITIONS[0],
|
| 564 |
+
}
|
| 565 |
+
for k, v in defaults.items():
|
| 566 |
+
if k not in st.session_state:
|
| 567 |
+
st.session_state[k] = v
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
def reset_session_state():
|
| 571 |
+
keys_to_remove = [
|
| 572 |
+
"last_results",
|
| 573 |
+
"warnings",
|
| 574 |
+
"errors",
|
| 575 |
+
]
|
| 576 |
+
for k in keys_to_remove:
|
| 577 |
+
if k in st.session_state:
|
| 578 |
+
del st.session_state[k]
|
| 579 |
+
# Reset inputs to defaults
|
| 580 |
+
init_session_state_defaults()
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
def run_calculations(inputs: Dict[str, Any]) -> Dict[str, Any]:
|
| 584 |
+
"""
|
| 585 |
+
Orchestrate reads, computes and returns a results dict.
|
| 586 |
+
Returns also warnings and any messages.
|
| 587 |
+
"""
|
| 588 |
+
# Validate key inputs
|
| 589 |
+
errors = []
|
| 590 |
+
warnings = []
|
| 591 |
+
|
| 592 |
+
unit_system = inputs["unit_system"]
|
| 593 |
+
# Material handling
|
| 594 |
+
mat_cat = inputs["material_category"]
|
| 595 |
+
mat_grade = inputs["material_grade"]
|
| 596 |
+
|
| 597 |
+
# Allowable stress S
|
| 598 |
+
if mat_cat == "User Defined":
|
| 599 |
+
S = inputs.get("user_defined_allowable")
|
| 600 |
+
if S is None or S <= 0:
|
| 601 |
+
raise ValueError("User-defined allowable stress must be a positive number")
|
| 602 |
+
mg = inputs.get("user_defined_group", "P1")
|
| 603 |
+
ce = inputs.get("user_defined_ce", 0.30)
|
| 604 |
+
is_pipe_grade = False
|
| 605 |
+
else:
|
| 606 |
+
S, wmsg = get_allowable_stress(mat_cat, mat_grade, inputs["design_temperature"], "USC" if unit_system == "USC" else "SI")
|
| 607 |
+
if wmsg:
|
| 608 |
+
warnings.append(wmsg)
|
| 609 |
+
mg = MATERIALS[mat_cat][mat_grade]["material_group"]
|
| 610 |
+
ce = MATERIALS[mat_cat][mat_grade]["carbon_equivalent"]
|
| 611 |
+
is_pipe_grade = MATERIALS[mat_cat][mat_grade].get("is_pipe_grade", False)
|
| 612 |
+
|
| 613 |
+
# Geometry conversions: unify units so functions get consistent units
|
| 614 |
+
# In this implementation we expect S and P to be in consistent units already (controller)
|
| 615 |
+
P = inputs["design_pressure"]
|
| 616 |
+
D = inputs["inside_diameter"]
|
| 617 |
+
R = D / 2.0
|
| 618 |
+
E = inputs["joint_efficiency"]
|
| 619 |
+
ca = inputs["corrosion_allowance"]
|
| 620 |
+
|
| 621 |
+
# Compute shell thicknesses
|
| 622 |
+
shell = calculate_shell_thickness(P, R, S, E)
|
| 623 |
+
shell_required = shell["required_thickness"]
|
| 624 |
+
# Add corrosion allowance to total shell thickness
|
| 625 |
+
total_shell = shell_required + ca
|
| 626 |
+
|
| 627 |
+
# Optionally compute atmospheric shell
|
| 628 |
+
atm_shell = None
|
| 629 |
+
if inputs.get("calc_atm_shell"):
|
| 630 |
+
# For atmospheric only, P = 0 -> thickness tends to 0. Use minimal thickness assumption.
|
| 631 |
+
# ASSUMPTION: Provide thickness for vacuum containment? Here we just return a message.
|
| 632 |
+
atm_shell = {"note": "Atmospheric shell check requested: with P=0 this calculation is geometry-specific. Provide loading (wind, vacuum) for meaningful thickness check."}
|
| 633 |
+
|
| 634 |
+
# Head thickness
|
| 635 |
+
head = calculate_head_thickness(P, D, S, E, inputs["head_type"])
|
| 636 |
+
head_required = head["required_thickness"]
|
| 637 |
+
total_head = head_required + ca
|
| 638 |
+
|
| 639 |
+
# Nozzle reinforcement
|
| 640 |
+
nozzle = calculate_nozzle_reinforcement(
|
| 641 |
+
inputs["nozzle_opening_diameter"], total_shell, inputs["nozzle_wall_thickness"],
|
| 642 |
+
inputs["pad_thickness"] if inputs["use_reinforcing_pad"] else 0.0,
|
| 643 |
+
inputs["pad_width"] if inputs["use_reinforcing_pad"] else 0.0,
|
| 644 |
+
mat_grade if mat_cat != "User Defined" else None,
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
# For pipe grades, mention 12.5% reduction in shell effective thickness for reinforcement checks
|
| 648 |
+
if is_pipe_grade:
|
| 649 |
+
reduced_shell_effective = total_shell * (1.0 - PIPE_THICKNESS_REDUCTION)
|
| 650 |
+
warnings.append(f"Pipe grade detected; applied {PIPE_THICKNESS_REDUCTION*100:.1f}% reduction to effective shell thickness for reinforcement checks: {reduced_shell_effective:.4f}")
|
| 651 |
else:
|
| 652 |
+
reduced_shell_effective = total_shell
|
| 653 |
+
|
| 654 |
+
# PWHT
|
| 655 |
+
# thickness for pwht in inches: if units SI, convert mm to inches (we assume inside_diameter units but thickness returned in same length units)
|
| 656 |
+
# ASSUMPTION: inputs thickness are in inches when unit_system == USC, mm when SI. For PWHT we need inches.
|
| 657 |
+
if unit_system == "SI":
|
| 658 |
+
# convert mm to inches: assume thickness in mm -> mm / 25.4
|
| 659 |
+
thickness_in_in = total_shell / 25.4
|
| 660 |
+
else:
|
| 661 |
+
thickness_in_in = total_shell
|
| 662 |
+
|
| 663 |
+
pwht = determine_pwht_requirements(mg, thickness_in_in, ce)
|
| 664 |
+
|
| 665 |
+
# Impact test / MDMT
|
| 666 |
+
design_mdmt = inputs["design_mdmt"]
|
| 667 |
+
impact = determine_impact_test_requirements(mg, design_mdmt, thickness_in_in, unit_system)
|
| 668 |
+
|
| 669 |
+
# MAWPs
|
| 670 |
+
shell_mawp = calculate_mawp_from_thickness(total_shell, R, S, E, "shell")
|
| 671 |
+
head_mawp = calculate_mawp_from_thickness(total_head, D, S, E, "head", inputs["head_type"])
|
| 672 |
+
governing_mawp = min(shell_mawp["mawp"], head_mawp["mawp"])
|
| 673 |
+
|
| 674 |
+
results = {
|
| 675 |
+
"shell": shell,
|
| 676 |
+
"total_shell_thickness": total_shell,
|
| 677 |
+
"head": head,
|
| 678 |
+
"total_head_thickness": total_head,
|
| 679 |
+
"nozzle": nozzle,
|
| 680 |
+
"pwht": pwht,
|
| 681 |
+
"impact": impact,
|
| 682 |
+
"shell_mawp": shell_mawp,
|
| 683 |
+
"head_mawp": head_mawp,
|
| 684 |
+
"governing_mawp": governing_mawp,
|
| 685 |
+
"reduced_shell_effective": reduced_shell_effective,
|
| 686 |
+
"warnings": warnings,
|
| 687 |
+
}
|
| 688 |
+
return results
|
| 689 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
|
| 691 |
+
# ---------------------------
|
| 692 |
+
# Streamlit app layout
|
| 693 |
+
# ---------------------------
|
|
|
|
|
|
|
|
|
|
| 694 |
|
| 695 |
+
def main():
|
| 696 |
+
st.set_page_config(page_title="ASME Section VIII Calculator", page_icon="🔧", layout="wide")
|
| 697 |
+
init_session_state_defaults()
|
| 698 |
+
|
| 699 |
+
st.markdown("<h2 style='color:#0b5fff'>🔧 ASME Section VIII — Preliminary Calculator</h2>", unsafe_allow_html=True)
|
| 700 |
+
st.caption("This tool is for preliminary design only. Final verification must be completed by a licensed professional engineer and checked against the latest ASME code editions.")
|
| 701 |
+
st.sidebar.title("Inputs")
|
| 702 |
+
|
| 703 |
+
# Unit system
|
| 704 |
+
unit_system = st.sidebar.selectbox("Unit System", ["USC", "SI"], index=0 if st.session_state["unit_system"] == "USC" else 1)
|
| 705 |
+
st.session_state["unit_system"] = unit_system
|
| 706 |
+
|
| 707 |
+
# ASME edition placeholder
|
| 708 |
+
st.session_state["asme_edition"] = st.sidebar.selectbox("ASME Section VIII Edition", SUPPORTED_ASME_EDITIONS, index=0)
|
| 709 |
+
|
| 710 |
+
# Basic design
|
| 711 |
+
pressure_label = "Design Pressure (psi)" if unit_system == "USC" else "Design Pressure (bar)"
|
| 712 |
+
temp_label = "Design Temperature (°F)" if unit_system == "USC" else "Design Temperature (°C)"
|
| 713 |
+
mdmt_label = "Design MDMT (°F)" if unit_system == "USC" else "Design MDMT (°C)"
|
| 714 |
+
length_label = "Inside Diameter (in)" if unit_system == "USC" else "Inside Diameter (mm)"
|
| 715 |
+
ca_label = "Corrosion Allowance (in)" if unit_system == "USC" else "Corrosion Allowance (mm)"
|
| 716 |
+
|
| 717 |
+
st.session_state["design_pressure"] = st.sidebar.number_input(pressure_label, value=float(st.session_state["design_pressure"]))
|
| 718 |
+
st.session_state["design_temperature"] = st.sidebar.number_input(temp_label, value=float(st.session_state["design_temperature"]))
|
| 719 |
+
st.session_state["design_mdmt"] = st.sidebar.number_input(mdmt_label, value=float(st.session_state["design_mdmt"]))
|
| 720 |
+
st.session_state["corrosion_allowance"] = st.sidebar.number_input(ca_label, value=float(st.session_state["corrosion_allowance"]))
|
| 721 |
+
|
| 722 |
+
st.sidebar.markdown("---")
|
| 723 |
+
st.session_state["inside_diameter"] = st.sidebar.number_input(length_label, value=float(st.session_state["inside_diameter"]))
|
| 724 |
+
st.session_state["head_type"] = st.sidebar.selectbox("Head Type", ["ellipsoidal", "torispherical", "hemispherical"], index=0 if st.session_state["head_type"] == "ellipsoidal" else 0)
|
| 725 |
+
st.session_state["joint_efficiency"] = st.sidebar.selectbox("Joint Efficiency (E)", [1.0, 0.95, 0.9, 0.85], index=0)
|
| 726 |
+
|
| 727 |
+
st.sidebar.markdown("---")
|
| 728 |
+
st.session_state["material_category"] = st.sidebar.selectbox("Material Category", ["Carbon Steel", "Stainless Steel", "User Defined"], index=0)
|
| 729 |
+
# material grade selection
|
| 730 |
+
if st.session_state["material_category"] == "User Defined":
|
| 731 |
+
st.session_state["material_grade"] = st.sidebar.text_input("Material Grade (user)", value="USER-MAT")
|
| 732 |
+
st.session_state["user_defined_allowable"] = st.sidebar.number_input("User-defined allowable stress (psi or MPa)", value=float(st.session_state["user_defined_allowable"]))
|
| 733 |
+
st.session_state["user_defined_group"] = st.sidebar.selectbox("User-defined material group", ["P1", "P8"], index=0)
|
| 734 |
+
st.session_state["user_defined_ce"] = st.sidebar.number_input("User-defined carbon equivalent (if known)", value=0.30)
|
| 735 |
+
else:
|
| 736 |
+
grades = list(MATERIALS[st.session_state["material_category"]].keys())
|
| 737 |
+
# select current grade if available
|
| 738 |
+
try:
|
| 739 |
+
idx = grades.index(st.session_state["material_grade"])
|
| 740 |
+
except Exception:
|
| 741 |
+
idx = 0
|
| 742 |
+
st.session_state["material_grade"] = grades[0]
|
| 743 |
+
st.session_state["material_grade"] = st.sidebar.selectbox("Material Grade", grades, index=idx)
|
| 744 |
+
|
| 745 |
+
st.sidebar.markdown("---")
|
| 746 |
+
st.session_state["nozzle_opening_diameter"] = st.sidebar.number_input("Nozzle Opening Diameter (same unit as vessel)", value=float(st.session_state["nozzle_opening_diameter"]))
|
| 747 |
+
st.session_state["nozzle_wall_thickness"] = st.sidebar.number_input("Nozzle Wall Thickness (same unit)", value=float(st.session_state["nozzle_wall_thickness"]))
|
| 748 |
+
st.session_state["use_reinforcing_pad"] = st.sidebar.checkbox("Use Reinforcing Pad", value=st.session_state["use_reinforcing_pad"])
|
| 749 |
+
if st.session_state["use_reinforcing_pad"]:
|
| 750 |
+
st.session_state["pad_thickness"] = st.sidebar.number_input("Pad Thickness (same unit)", value=float(st.session_state["pad_thickness"]))
|
| 751 |
+
st.session_state["pad_width"] = st.sidebar.number_input("Pad Width (same unit)", value=float(st.session_state["pad_width"]))
|
| 752 |
+
|
| 753 |
+
st.sidebar.markdown("---")
|
| 754 |
+
st.session_state["calc_atm_shell"] = st.sidebar.checkbox("Also calculate shell at atmospheric pressure", value=st.session_state["calc_atm_shell"])
|
| 755 |
+
|
| 756 |
+
# Buttons
|
| 757 |
+
col1, col2 = st.columns([1, 1])
|
| 758 |
+
run_calc = col1.button("Calculate")
|
| 759 |
+
reset_btn = col2.button("Reset to defaults")
|
| 760 |
+
|
| 761 |
+
if reset_btn:
|
| 762 |
+
reset_session_state()
|
| 763 |
+
st.experimental_rerun()
|
| 764 |
+
|
| 765 |
+
# Prepare inputs dict
|
| 766 |
+
inputs = {
|
| 767 |
+
"unit_system": st.session_state["unit_system"],
|
| 768 |
+
"design_pressure": float(st.session_state["design_pressure"]),
|
| 769 |
+
"design_temperature": float(st.session_state["design_temperature"]),
|
| 770 |
+
"design_mdmt": float(st.session_state["design_mdmt"]),
|
| 771 |
+
"corrosion_allowance": float(st.session_state["corrosion_allowance"]),
|
| 772 |
+
"inside_diameter": float(st.session_state["inside_diameter"]),
|
| 773 |
+
"head_type": st.session_state["head_type"],
|
| 774 |
+
"joint_efficiency": float(st.session_state["joint_efficiency"]),
|
| 775 |
+
"material_category": st.session_state["material_category"],
|
| 776 |
+
"material_grade": st.session_state["material_grade"],
|
| 777 |
+
"user_defined_allowable": float(st.session_state.get("user_defined_allowable", 0.0)),
|
| 778 |
+
"user_defined_group": st.session_state.get("user_defined_group", "P1"),
|
| 779 |
+
"user_defined_ce": float(st.session_state.get("user_defined_ce", 0.30)),
|
| 780 |
+
"nozzle_opening_diameter": float(st.session_state["nozzle_opening_diameter"]),
|
| 781 |
+
"nozzle_wall_thickness": float(st.session_state["nozzle_wall_thickness"]),
|
| 782 |
+
"use_reinforcing_pad": bool(st.session_state["use_reinforcing_pad"]),
|
| 783 |
+
"pad_thickness": float(st.session_state.get("pad_thickness", 0.0)),
|
| 784 |
+
"pad_width": float(st.session_state.get("pad_width", 0.0)),
|
| 785 |
+
"calc_atm_shell": bool(st.session_state.get("calc_atm_shell", False)),
|
| 786 |
+
}
|
| 787 |
|
| 788 |
+
# Run calculations only when requested
|
| 789 |
+
if run_calc:
|
| 790 |
+
try:
|
| 791 |
+
results = run_calculations(inputs)
|
| 792 |
+
st.session_state["last_results"] = results
|
| 793 |
+
st.session_state["warnings"] = results.get("warnings", [])
|
| 794 |
+
st.success("Calculations completed.")
|
| 795 |
+
except Exception as e:
|
| 796 |
+
st.session_state["errors"] = str(e)
|
| 797 |
+
st.error(f"Error during calculation: {e}")
|
| 798 |
+
|
| 799 |
+
# show warnings/errors if present
|
| 800 |
+
if "warnings" in st.session_state and st.session_state["warnings"]:
|
| 801 |
+
for w in st.session_state["warnings"]:
|
| 802 |
+
st.warning(w)
|
| 803 |
+
if "errors" in st.session_state:
|
| 804 |
+
st.error(st.session_state["errors"])
|
| 805 |
+
|
| 806 |
+
# Tabs for outputs
|
| 807 |
+
tab1, tab2, tab3, tab4, tab5 = st.tabs(["Shell", "Head", "Nozzle", "PWHT & Impact", "Summary"])
|
| 808 |
+
|
| 809 |
+
results = st.session_state.get("last_results")
|
| 810 |
|
| 811 |
with tab1:
|
| 812 |
st.header("Shell Thickness (UG-27)")
|
| 813 |
+
if results:
|
| 814 |
+
shell = results["shell"]
|
| 815 |
+
st.write("**Formula used:**", shell["formula_used"])
|
| 816 |
+
st.write("**Condition:**", shell["condition_check"])
|
| 817 |
+
units_len = "in" if unit_system == "USC" else "mm"
|
| 818 |
+
st.metric("Circumferential thickness", f"{format_value(shell['circumferential_thickness'], units_len, True)} {units_len}")
|
| 819 |
+
st.metric("Longitudinal thickness", f"{format_value(shell['longitudinal_thickness'], units_len, True)} {units_len}")
|
| 820 |
+
st.metric("Required thickness (governing)", f"{format_value(shell['required_thickness'], units_len, True)} {units_len}")
|
| 821 |
+
st.markdown("**Total shell thickness including corrosion allowance:**")
|
| 822 |
+
st.write(f"{format_value(results['total_shell_thickness'], units_len, True)} {units_len}")
|
| 823 |
+
|
| 824 |
+
with st.expander("Show step-by-step shell calculations"):
|
| 825 |
+
st.code(f"Inputs: P={inputs['design_pressure']}, R={inputs['inside_diameter']/2.0}, S={get_allowable_stress(inputs['material_category'], inputs['material_grade'], inputs['design_temperature'], 'USC' if unit_system=='USC' else 'SI')[0]}, E={inputs['joint_efficiency']}")
|
| 826 |
+
# Show computed values numerically
|
| 827 |
+
st.write(shell)
|
| 828 |
+
else:
|
| 829 |
+
st.info("Press **Calculate** to run shell thickness calculations.")
|
| 830 |
|
| 831 |
with tab2:
|
| 832 |
st.header("Head Thickness (UG-32)")
|
| 833 |
+
if results:
|
| 834 |
+
head = results["head"]
|
| 835 |
+
units_len = "in" if unit_system == "USC" else "mm"
|
| 836 |
+
st.write("**Head Type:**", head["head_type"].title())
|
| 837 |
+
st.write("**Formula used:**", head["formula_used"])
|
| 838 |
+
st.metric("Required head thickness", f"{format_value(results['head']['required_thickness'], units_len, True)} {units_len}")
|
| 839 |
+
st.write("Total head thickness including corrosion allowance:", f"{format_value(results['total_head_thickness'], units_len, True)} {units_len}")
|
| 840 |
+
with st.expander("Show step-by-step head calculations"):
|
| 841 |
+
st.write(head["intermediate_values"])
|
| 842 |
+
else:
|
| 843 |
+
st.info("Press **Calculate** to run head thickness calculations.")
|
| 844 |
|
| 845 |
with tab3:
|
| 846 |
+
st.header("Nozzle Reinforcement (UG-37 approx)")
|
| 847 |
+
if results:
|
| 848 |
+
nozzle = results["nozzle"]
|
| 849 |
+
units_len = "in" if unit_system == "USC" else "mm"
|
| 850 |
+
st.write("Assumptions:")
|
| 851 |
+
for a in nozzle["assumptions"]:
|
| 852 |
+
st.write("-", a)
|
| 853 |
+
st.write("Required area:", f"{format_value(nozzle['area_required'], units_len)} {units_len}^2")
|
| 854 |
+
st.write("Available area:", f"{format_value(nozzle['area_available_total'], units_len)} {units_len}^2")
|
| 855 |
+
if nozzle["reinforcement_adequate"]:
|
| 856 |
+
st.success(f"Reinforcement adequate (SF = {nozzle['safety_factor']:.2f})")
|
| 857 |
+
else:
|
| 858 |
+
st.error(f"Reinforcement inadequate (SF = {nozzle['safety_factor']:.2f})")
|
| 859 |
+
with st.expander("Show step-by-step nozzle calculation"):
|
| 860 |
+
st.write(nozzle)
|
| 861 |
else:
|
| 862 |
+
st.info("Press **Calculate** to run nozzle reinforcement calculations.")
|
| 863 |
|
| 864 |
with tab4:
|
| 865 |
+
st.header("PWHT & Impact Test")
|
| 866 |
+
if results:
|
| 867 |
+
pwht = results["pwht"]
|
| 868 |
+
impact = results["impact"]
|
| 869 |
+
|
| 870 |
+
st.subheader("PWHT")
|
| 871 |
+
if pwht["pwht_required"]:
|
| 872 |
+
st.error("PWHT REQUIRED")
|
| 873 |
+
else:
|
| 874 |
+
st.success("PWHT not required by preliminary check")
|
| 875 |
st.write("Reasons:")
|
| 876 |
+
for r in pwht["reasons"]:
|
| 877 |
+
st.write("-", r)
|
| 878 |
+
if pwht["temperature_range"]:
|
| 879 |
+
st.write("Suggested PWHT temperature range:", pwht["temperature_range"])
|
| 880 |
+
st.write("Suggested hold time:", pwht["hold_time"])
|
| 881 |
+
|
| 882 |
+
st.subheader("Impact Test (MDMT check)")
|
| 883 |
+
# show rated and design MDMT
|
| 884 |
+
rated = impact["rated_mdmt"]
|
| 885 |
+
design = impact["design_mdmt"]
|
| 886 |
+
if unit_system == "SI":
|
| 887 |
+
st.write("Rated MDMT (°C):", f"{format_value(rated, 'C')} °C")
|
| 888 |
+
st.write("Design MDMT (°C):", f"{format_value(design, 'C')} °C")
|
| 889 |
+
else:
|
| 890 |
+
st.write("Rated MDMT (°F):", f"{format_value(rated, 'F')} °F")
|
| 891 |
+
st.write("Design MDMT (°F):", f"{format_value(design, 'F')} °F")
|
| 892 |
+
|
| 893 |
+
if impact["impact_test_required"]:
|
| 894 |
+
st.error("Impact test required — Charpy V-notch")
|
| 895 |
+
st.write("Suggested test temperature:", f"{impact['test_temperature']:.1f} {'°C' if unit_system=='SI' else '°F'}")
|
| 896 |
+
else:
|
| 897 |
+
st.success("Impact test not required by preliminary check")
|
| 898 |
+
st.write("Notes:", impact["notes"])
|
| 899 |
+
with st.expander("Show step-by-step MDMT logic and notes"):
|
| 900 |
+
st.write(impact)
|
| 901 |
else:
|
| 902 |
+
st.info("Press **Calculate** to run PWHT and impact checks.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
|
| 904 |
with tab5:
|
| 905 |
+
st.header("Summary")
|
| 906 |
+
if results:
|
| 907 |
+
# Build DataFrame summary
|
| 908 |
+
data = {
|
| 909 |
+
"Parameter": [
|
| 910 |
+
"Design Pressure",
|
| 911 |
+
"Design Temperature",
|
| 912 |
+
"Design MDMT",
|
| 913 |
+
"Inside Diameter",
|
| 914 |
+
"Material",
|
| 915 |
+
"Allowable Stress",
|
| 916 |
+
"Joint Efficiency",
|
| 917 |
+
"Corrosion Allowance",
|
| 918 |
+
"Shell Required Thickness",
|
| 919 |
+
"Shell Total Thickness",
|
| 920 |
+
"Head Required Thickness",
|
| 921 |
+
"Head Total Thickness",
|
| 922 |
+
"Governing MAWP",
|
| 923 |
+
],
|
| 924 |
+
"Value": []
|
| 925 |
+
}
|
| 926 |
+
# allowable stress fetch (again) but safe
|
| 927 |
+
if inputs["material_category"] == "User Defined":
|
| 928 |
+
allowable = inputs["user_defined_allowable"]
|
| 929 |
+
else:
|
| 930 |
+
allowable, _ = get_allowable_stress(inputs["material_category"], inputs["material_grade"], inputs["design_temperature"], "USC" if unit_system == "USC" else "SI")
|
| 931 |
+
units_len = "in" if unit_system == "USC" else "mm"
|
| 932 |
+
units_pres = "psi" if unit_system == "USC" else "MPa"
|
| 933 |
+
data["Value"] = [
|
| 934 |
+
f"{inputs['design_pressure']} {'psi' if unit_system=='USC' else 'bar'}",
|
| 935 |
+
f"{inputs['design_temperature']} {'°F' if unit_system=='USC' else '°C'}",
|
| 936 |
+
f"{inputs['design_mdmt']} {'°F' if unit_system=='USC' else '°C'}",
|
| 937 |
+
f"{inputs['inside_diameter']} {units_len}",
|
| 938 |
+
f"{inputs['material_grade']}",
|
| 939 |
+
f"{format_value(allowable, units_pres)} {units_pres}",
|
| 940 |
+
f"{inputs['joint_efficiency']}",
|
| 941 |
+
f"{inputs['corrosion_allowance']} {units_len}",
|
| 942 |
+
f"{format_value(results['shell']['required_thickness'], units_len, True)} {units_len}",
|
| 943 |
+
f"{format_value(results['total_shell_thickness'], units_len, True)} {units_len}",
|
| 944 |
+
f"{format_value(results['head']['required_thickness'], units_len, True)} {units_len}",
|
| 945 |
+
f"{format_value(results['total_head_thickness'], units_len, True)} {units_len}",
|
| 946 |
+
f"{format_value(results['governing_mawp'], 'psi' if unit_system=='USC' else 'bar')} {'psi' if unit_system=='USC' else 'bar'}",
|
| 947 |
+
]
|
| 948 |
+
df = pd.DataFrame(data)
|
| 949 |
+
st.dataframe(df, use_container_width=True)
|
| 950 |
+
st.write("Report generated:", datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
| 951 |
+
|
| 952 |
+
# Exports
|
| 953 |
+
csv = df.to_csv(index=False).encode("utf-8")
|
| 954 |
+
st.download_button("Download Summary CSV", csv, file_name="asme_summary.csv", mime="text/csv")
|
| 955 |
+
|
| 956 |
+
# PDF export (optional)
|
| 957 |
+
try:
|
| 958 |
+
from reportlab.lib.pagesizes import letter
|
| 959 |
+
from reportlab.pdfgen import canvas
|
| 960 |
+
# create PDF in-memory
|
| 961 |
+
from io import BytesIO
|
| 962 |
+
buffer = BytesIO()
|
| 963 |
+
c = canvas.Canvas(buffer, pagesize=letter)
|
| 964 |
+
c.setFont("Helvetica", 12)
|
| 965 |
+
c.drawString(30, 750, "ASME Section VIII — Preliminary Summary Report")
|
| 966 |
+
y = 730
|
| 967 |
+
for i, (param, val) in df.iterrows():
|
| 968 |
+
c.drawString(30, y, f"{val['Parameter']}: {val['Value']}")
|
| 969 |
+
y -= 15
|
| 970 |
+
if y < 50:
|
| 971 |
+
c.showPage()
|
| 972 |
+
y = 750
|
| 973 |
+
c.drawString(30, y - 20, f"Generated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| 974 |
+
c.save()
|
| 975 |
+
pdf_data = buffer.getvalue()
|
| 976 |
+
buffer.close()
|
| 977 |
+
st.download_button("Download Summary PDF", pdf_data, file_name="asme_summary.pdf", mime="application/pdf")
|
| 978 |
+
except Exception:
|
| 979 |
+
st.info("PDF export requires optional package 'reportlab'. Install to enable PDF export.")
|
| 980 |
+
|
| 981 |
+
else:
|
| 982 |
+
st.info("Press **Calculate** to produce a summary.")
|
| 983 |
|
| 984 |
+
# Footer / disclaimer
|
| 985 |
+
st.markdown("---")
|
| 986 |
+
st.caption("Disclaimer: This app provides preliminary calculations only. Verify final design with a licensed professional engineer and use the latest ASME code edition.")
|
| 987 |
|
| 988 |
if __name__ == "__main__":
|
| 989 |
main()
|