Umar4321 commited on
Commit
1420dc4
·
verified ·
1 Parent(s): 2716049

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +836 -278
app.py CHANGED
@@ -1,129 +1,286 @@
1
- import streamlit as st
2
- import pandas as pd
3
- import numpy as np
4
- import plotly.graph_objects as go
5
- from datetime import datetime
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import math
 
 
 
 
 
 
 
 
7
 
8
  # ---------------------------
9
- # Streamlit ASME VIII App
10
- # Cleaned, self-contained, ready for deployment to Hugging Face Spaces
11
- # Requirements: streamlit, pandas, numpy, plotly
12
  # ---------------------------
 
 
 
 
13
 
14
- st.set_page_config(
15
- page_title="ASME Section VIII Calculator",
16
- page_icon="🔧",
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
- # Material database (compact)
 
 
 
37
  # ---------------------------
38
- MATERIALS = {
 
39
  "Carbon Steel": {
40
- "SA-516 Grade 60": {
 
 
 
 
 
 
 
 
 
41
  "allowable_stress": {
42
- "USC": {-20: 15000, 100: 15000, 200: 15000, 300: 15000, 400: 15000, 500: 14750, 600: 14200, 650: 13100},
43
- "SI": {-29: 103, 38: 103, 93: 103, 149: 103, 204: 103, 260: 102, 316: 98, 343: 90},
 
 
 
 
 
 
 
 
 
44
  },
45
  "carbon_equivalent": 0.30,
46
  "material_group": "P1",
 
47
  },
48
- "SA-516 Grade 70": {
49
  "allowable_stress": {
50
- "USC": {-20: 17500, 100: 17500, 200: 17500, 300: 17500, 400: 17500, 500: 17200, 600: 16550, 650: 15300},
51
- "SI": {-29: 121, 38: 121, 93: 121, 149: 121, 204: 121, 260: 119, 316: 114, 343: 105},
52
  },
53
  "carbon_equivalent": 0.31,
54
  "material_group": "P1",
 
55
  },
56
  },
57
  "Stainless Steel": {
58
- "SA-240 Type 304": {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  "allowable_stress": {
60
- "USC": {-20: 18800, 100: 18800, 200: 16200, 300: 15100, 400: 14200, 500: 13500, 600: 12800, 700: 12100},
61
- "SI": {-29: 130, 38: 130, 93: 112, 149: 104, 204: 98, 260: 93, 316: 88, 371: 83},
62
  },
63
  "carbon_equivalent": 0.08,
64
  "material_group": "P8",
 
65
  },
66
- "SA-240 Type 316": {
67
  "allowable_stress": {
68
- "USC": {-20: 18800, 100: 18800, 200: 16600, 300: 15600, 400: 14800, 500: 14200, 600: 13600, 700: 13000},
69
- "SI": {-29: 130, 38: 130, 93: 114, 149: 107, 204: 102, 260: 98, 316: 94, 371: 90},
70
  },
71
  "carbon_equivalent": 0.08,
72
  "material_group": "P8",
 
73
  },
74
  },
 
75
  }
76
 
77
  # ---------------------------
78
  # Helper functions
79
  # ---------------------------
80
 
81
- def get_allowable_stress(material_category, material_grade, temperature, units):
82
- """Interpolate allowable stress table for the selected unit system."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  try:
84
- stress_data = MATERIALS[material_category][material_grade]["allowable_stress"][units]
85
- temps = sorted(stress_data.keys())
86
- stresses = [stress_data[t] for t in temps]
87
- if temperature <= temps[0]:
88
- return stresses[0]
89
- if temperature >= temps[-1]:
90
- return stresses[-1]
91
- return float(np.interp(temperature, temps, stresses))
92
- except Exception:
93
- # Fallback safe value
94
- return 15000.0 if units == "USC" else 103.0
95
-
96
-
97
- def convert_temperature(temp, from_unit, to_unit):
 
 
 
98
  if from_unit == to_unit:
99
- return temp
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 (temp - 32.0) * 5.0 / 9.0
104
- return temp
 
 
105
 
106
 
107
- def calculate_shell_thickness(P, R, S, E):
108
- """UG-27 shell thickness. P = design pressure, R = inside radius, S = allowable stress, E = joint eff."""
109
- # Use the UG-27 circumferential and longitudinal formulas
110
- if P <= 0.385 * S * E:
111
- t_circum = (P * R) / (S * E - 0.6 * P)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  formula_used = "t = PR/(SE - 0.6P)"
113
- condition = f"P ≤ 0.385SE: {P:.3f} ≤ {0.385 * S * E:.3f}"
114
  else:
115
- t_circum = (P * R) / (S * E + 0.4 * P)
116
  formula_used = "t = PR/(SE + 0.4P)"
117
- condition = f"P > 0.385SE: {P:.3f} > {0.385 * S * E:.3f}"
118
 
 
119
  t_long = (P * R) / (2 * S * E + 0.4 * P)
120
- t_required = max(t_circum, t_long)
121
- governing = "Circumferential" if t_circum >= t_long else "Longitudinal"
 
122
 
123
  return {
124
- "circumferential_thickness": float(t_circum),
125
  "longitudinal_thickness": float(t_long),
126
- "required_thickness": float(t_required),
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
- """UG-32 head thickness (basic implementations). D is inside diameter."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {"required_thickness": float(t), "head_type": head_type, "formula_used": formula, "asme_clause": clause}
 
 
 
 
 
 
157
 
158
 
159
- def calculate_nozzle_reinforcement(d, t_shell, t_nozzle, t_pad, pad_width):
160
- """Very conservative, simplified area method for nozzle reinforcement (UG-37 family).
161
- d should be the nozzle inside diameter (or opening) — units must be consistent with thickness."""
162
- A_required = d * t_shell
163
- # conservative shell area (A1) assumed zero if no excess thickness
164
- A_shell = 0.0
165
- # nozzle area contribution (A2) approximate
166
- height_limit = min(2.5 * t_shell, 2.5 * t_nozzle)
 
 
 
 
 
 
 
 
167
  A_nozzle = t_nozzle * height_limit
168
- # pad area contribution
169
- A_pad = t_pad * pad_width if t_pad > 0 and pad_width > 0 else 0.0
170
- A_welds = 0.0
171
- A_available = A_shell + A_nozzle + A_pad + A_welds
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": A_available >= A_required,
181
- "safety_factor": float(A_available / A_required) if A_required > 0 else 0.0,
182
- "asme_clause": "UG-37 through UG-45",
 
183
  }
184
 
185
 
186
- def determine_pwht_requirements(material_category, material_grade, thickness_in, units):
187
- """Very simplified PWHT decision logic based on UCS-56 guidance."""
188
- if units == "SI":
189
- thickness_inch = thickness_in / 25.4
190
- else:
191
- thickness_inch = thickness_in
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 mg == "P1":
203
- if thickness_inch > 1.25:
204
  pwht_required = True
205
- reasons.append("Thickness > 1.25 inches (UCS-56)")
206
- temp_range = "1100°F - 1200°F" if units == "USC" else "593°C - 649°C"
207
- hold_time = f"Minimum {max(1, thickness_inch):.1f} hours"
208
- if ce > 0.35:
209
  pwht_required = True
210
- reasons.append("Carbon equivalent > 0.35% (UCS-56)")
211
- elif mg == "P8":
212
- reasons.append("Stainless steel: PWHT generally not required (consult spec)")
 
 
213
 
214
  if not pwht_required:
215
- reasons.append("Below thickness and CE limits")
216
 
217
  return {"pwht_required": pwht_required, "reasons": reasons, "temperature_range": temp_range, "hold_time": hold_time}
218
 
219
 
220
- def determine_impact_test_requirements(material_category, material_grade, design_temp, thickness_in, units):
221
- """Very simplified impact test exemption logic (UCS-66 style)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  if units == "SI":
223
- design_temp_f = convert_temperature(design_temp, "C", "F")
224
- thickness_inch = thickness_in / 25.4
225
  else:
226
- design_temp_f = design_temp
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
- impact_required = design_temp_f < ex_temp
248
- test_temp = design_temp_f - 30
 
 
 
 
 
 
 
 
249
 
250
  return {
251
- "impact_test_required": impact_required,
252
- "design_temperature_f": design_temp_f,
253
- "exemption_temperature_f": ex_temp,
254
- "test_temperature_f": test_temp,
 
 
255
  }
256
 
257
 
258
- def calculate_mawp(t, R_or_D, S, E, component_type="shell", head_type="ellipsoidal"):
259
- """Calculate MAWP from thickness (t), geometry and allowable stress S.
260
- For shell: R_or_D is inside radius; for head we accept D for formulas used earlier."""
 
 
 
 
 
 
 
 
 
 
 
 
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 = SEt/(R + 0.6t)"
265
  else:
266
- # approximate head MAWP using ellipsoidal/hemispherical style
 
 
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 main():
286
- st.markdown('<div class="main-header">🔧 ASME Section VIII Pressure Vessel Calculator</div>', unsafe_allow_html=True)
287
-
288
- # Sidebar
289
- st.sidebar.title("Input Parameters")
290
- units = st.sidebar.selectbox("Unit System", ["USC", "SI"], index=0)
291
- unit_system = "USC" if units == "USC" else "SI"
292
-
293
- if unit_system == "USC":
294
- pressure_unit = "psi"
295
- length_unit = "in"
296
- temp_unit = "°F"
297
- stress_unit = "psi"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  else:
299
- pressure_unit = "bar"
300
- length_unit = "mm"
301
- temp_unit = "°C"
302
- stress_unit = "MPa"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- st.sidebar.subheader("Material & Geometry")
310
- mat_cat = st.sidebar.selectbox("Material Category", list(MATERIALS.keys()))
311
- mat_grade = st.sidebar.selectbox("Material Grade", list(MATERIALS[mat_cat].keys()))
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
- # Derived
317
- inside_radius = inside_diameter / 2.0
318
- allowable_stress = get_allowable_stress(mat_cat, mat_grade, design_temperature, unit_system)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
 
320
- tab1, tab2, tab3, tab4, tab5 = st.tabs(["Shell", "Head", "Nozzle", "PWHT", "Summary"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
 
322
  with tab1:
323
  st.header("Shell Thickness (UG-27)")
324
- shell = calculate_shell_thickness(design_pressure, inside_radius, allowable_stress, joint_eff)
325
- st.write("**Formula used:**", shell["formula_used"])
326
- st.write("**Condition:**", shell["condition_check"])
327
- st.write("Circumferential thickness:", f"{shell['circumferential_thickness']:.4f} {length_unit}")
328
- st.write("Longitudinal thickness:", f"{shell['longitudinal_thickness']:.4f} {length_unit}")
329
- st.write("Required thickness:", f"{shell['required_thickness']:.4f} {length_unit}")
 
 
 
 
 
 
 
 
 
 
 
330
 
331
  with tab2:
332
  st.header("Head Thickness (UG-32)")
333
- head = calculate_head_thickness(design_pressure, inside_diameter, allowable_stress, joint_eff, head_type)
334
- st.write("**Head Type:**", head["head_type"].title())
335
- st.write("**Formula used:**", head["formula_used"])
336
- st.write("Required thickness:", f"{head['required_thickness']:.4f} {length_unit}")
 
 
 
 
 
 
 
337
 
338
  with tab3:
339
- st.header("Nozzle Reinforcement (UG-37)")
340
- nozzle_d = st.number_input("Nozzle Opening Diameter (same unit as vessel)", value=6.0)
341
- nozzle_t = st.number_input("Nozzle Wall Thickness", value=0.5)
342
- use_pad = st.checkbox("Use Reinforcing Pad")
343
- pad_t = pad_w = 0.0
344
- if use_pad:
345
- pad_t = st.number_input("Pad Thickness", value=0.25)
346
- pad_w = st.number_input("Pad Width", value=8.0)
347
-
348
- shell_total_thickness = shell['required_thickness'] + corrosion_allowance
349
- nozzle_res = calculate_nozzle_reinforcement(nozzle_d, shell_total_thickness, nozzle_t, pad_t, pad_w)
350
- st.write("Required area:", f"{nozzle_res['area_required']:.4f} {length_unit}^2")
351
- st.write("Available area:", f"{nozzle_res['area_available_total']:.4f} {length_unit}^2")
352
- if nozzle_res['reinforcement_adequate']:
353
- st.success(f"Reinforcement adequate (SF={nozzle_res['safety_factor']:.2f})")
354
  else:
355
- st.error(f"Reinforcement inadequate (SF={nozzle_res['safety_factor']:.2f})")
356
 
357
  with tab4:
358
- st.header("PWHT & Impact")
359
- total_thick = shell['required_thickness'] + corrosion_allowance
360
- pwht = determine_pwht_requirements(mat_cat, mat_grade, total_thick, unit_system)
361
- impact = determine_impact_test_requirements(mat_cat, mat_grade, design_temperature, total_thick, unit_system)
362
-
363
- st.subheader("PWHT")
364
- if pwht['pwht_required']:
365
- st.error("PWHT REQUIRED")
 
 
366
  st.write("Reasons:")
367
- for r in pwht['reasons']:
368
- st.write(f"- {r}")
369
- st.write("Temperature range:", pwht['temperature_range'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  else:
371
- st.success("PWHT not required")
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 Report")
384
- total_shell = shell['required_thickness'] + corrosion_allowance
385
- total_head = head['required_thickness'] + corrosion_allowance
386
-
387
- shell_mawp = calculate_mawp(total_shell, inside_radius, allowable_stress, joint_eff, "shell")
388
- head_mawp = calculate_mawp(total_head, inside_diameter, allowable_stress, joint_eff, "head", head_type)
389
- governing_mawp = min(shell_mawp['mawp'], head_mawp['mawp'])
390
-
391
- st.metric("Governing MAWP", f"{governing_mawp:.2f} {pressure_unit}")
392
- st.write("Shell MAWP:", f"{shell_mawp['mawp']:.2f} {pressure_unit}")
393
- st.write("Head MAWP:", f"{head_mawp['mawp']:.2f} {pressure_unit}")
394
-
395
- df = pd.DataFrame({
396
- "Parameter": [
397
- "Design Pressure",
398
- "Design Temperature",
399
- "Inside Diameter",
400
- "Material",
401
- "Allowable Stress",
402
- "Joint Efficiency",
403
- "Corrosion Allowance",
404
- "Shell Required Thickness",
405
- "Shell Total Thickness",
406
- "Head Required Thickness",
407
- "Head Total Thickness",
408
- ],
409
- "Value": [
410
- f"{design_pressure} {pressure_unit}",
411
- f"{design_temperature} {temp_unit}",
412
- f"{inside_diameter} {length_unit}",
413
- f"{mat_grade}",
414
- f"{allowable_stress:.0f} {stress_unit}",
415
- str(joint_eff),
416
- f"{corrosion_allowance} {length_unit}",
417
- f"{shell['required_thickness']:.4f} {length_unit}",
418
- f"{total_shell:.4f} {length_unit}",
419
- f"{head['required_thickness']:.4f} {length_unit}",
420
- f"{total_head:.4f} {length_unit}",
421
- ],
422
- })
423
-
424
- st.dataframe(df, use_container_width=True)
425
- st.markdown("---")
426
- st.caption("Note: This tool is for preliminary design only. Final calculations must be verified by a licensed engineer and checked against the latest ASME code editions.")
427
- st.write("Report generated:", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()