Umar4321 commited on
Commit
1e0ba2b
·
verified ·
1 Parent(s): 80419a4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +322 -985
app.py CHANGED
@@ -1,1020 +1,357 @@
1
  #!/usr/bin/env python3
2
  """
3
- ASME Section VIIIPreliminary 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
- - Improved UG-37-style nozzle reinforcement area method (conservative 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.1" # bumped for nozzle + edition + E updates
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", "2025", "custom"] # added 2025
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,
287
- "asme_clause": "UG-27",
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(
343
- d_opening: float,
344
- t_shell_required: float,
345
- t_nozzle: float,
346
- t_pad: float,
347
- pad_width: float,
348
- material_grade: Optional[str] = None,
349
- shell_local_extra_thickness: float = 0.0,
350
- weld_throat: float = 0.0,
351
- weld_efficiency: float = 0.7,
352
- ) -> Dict[str, Any]:
353
  """
354
- Improved simplified UG-37 area method approximation.
355
-
356
- Method (conservative approximations):
357
- - Required area: A_req = d_opening * t_shell_required
358
- - Nozzle contribution: A_nozzle = pi * d_opening * t_nozzle (cylindrical projection)
359
- # APPROXIMATION: Using circumference*thickness as projected reinforcement area.
360
- - Pad contribution: A_pad = pad_width * t_pad * 2 (count both faces as conservative rectangular pads)
361
- - Shell local extra thickness contribution: A_shell_local = pi * d_opening * shell_local_extra_thickness
362
- - Weld contribution: A_weld = weld_throat * pi * d_opening * weld_efficiency
363
- - For pipe grades (SA-106B, SA-53B-ERW, SA-312-*) apply 12.5% thickness reduction to nozzle/pad/weld contributions.
364
- - Assumptions and limitations are returned in 'assumptions' and should be reviewed by an engineer.
365
  """
366
- # Input validation
367
- if d_opening <= 0:
368
- raise ValueError("Nozzle opening diameter must be positive.")
369
- if t_shell_required <= 0:
370
- raise ValueError("Shell required thickness must be positive.")
371
- if t_nozzle <= 0:
372
- raise ValueError("Nozzle wall thickness must be positive.")
373
-
374
- A_required = d_opening * t_shell_required # projection length * thickness
375
-
376
- # Nozzle cylindrical contribution (projection)
377
- A_nozzle = math.pi * d_opening * t_nozzle # circumference * thickness (approx)
378
- # Pad contribution: two faces conservative rectangular pad
379
- A_pad = pad_width * t_pad * 2.0 if (t_pad and pad_width) else 0.0
380
- # Shell local extra thickness contribution (if user provides local reinforcement)
381
- A_shell_local = math.pi * d_opening * shell_local_extra_thickness if shell_local_extra_thickness else 0.0
382
- # Weld contribution
383
- A_weld = weld_throat * math.pi * d_opening * weld_efficiency if weld_throat and weld_efficiency else 0.0
384
-
385
- reduction_info = ""
386
- # check pipe grade
387
- if material_grade:
388
- for cat in MATERIALS:
389
- mg = MATERIALS[cat].get(material_grade)
390
- if mg:
391
- if mg.get("is_pipe_grade"):
392
- # apply reduction to contributions that rely on nominal thickness (nozzle, pad, weld)
393
- A_nozzle = A_nozzle * (1.0 - PIPE_THICKNESS_REDUCTION)
394
- A_pad = A_pad * (1.0 - PIPE_THICKNESS_REDUCTION)
395
- A_weld = A_weld * (1.0 - PIPE_THICKNESS_REDUCTION)
396
- reduction_info = f"Applied {PIPE_THICKNESS_REDUCTION*100:.1f}% pipe thickness reduction to nozzle/pad/weld contributions."
397
- break
398
-
399
- A_available = A_shell_local + A_nozzle + A_pad + A_weld
400
-
401
- adequate = A_available >= A_required
402
- safety_factor = (A_available / A_required) if A_required > 0 else float("inf")
403
-
404
- assumptions = [
405
- "# ASSUMPTION: Using projection-area approximations (circumference*thickness) for nozzle contribution.",
406
- "# APPROXIMATION: Pad is modeled as rectangular and both faces are counted for conservatism.",
407
- "# ASSUMPTION: Shell local extra thickness is approximated as circumference * extra_thickness.",
408
- "# ASSUMPTION: Weld contribution approximated as weld_throat * circumference * efficiency.",
409
- "Real UG-37 uses more exact geometric projections and weld strength contribution; this is conservative and simplified.",
410
- ]
411
- if reduction_info:
412
- assumptions.append(reduction_info)
413
-
414
- return {
415
- "area_required": float(A_required),
416
- "area_shell_local": float(A_shell_local),
417
- "area_nozzle": float(A_nozzle),
418
- "area_pad": float(A_pad),
419
- "area_weld": float(A_weld),
420
- "area_available_total": float(A_available),
421
- "reinforcement_adequate": bool(adequate),
422
- "safety_factor": float(safety_factor),
423
- "assumptions": assumptions,
424
- "asme_clause": "UG-37 (approx)",
425
- }
426
-
427
-
428
- def determine_pwht_requirements(material_group: str, thickness_in_in: float, carbon_equivalent: float) -> Dict[str, Any]:
429
  """
430
- Very simplified PWHT logic following UCS-56 style rules:
431
- - ASSUMPTION: PWHT recommended if thickness > 1.25 in for P1 group or CE > 0.35
 
 
432
  """
433
- if thickness_in_in < 0:
434
- raise ValueError("Thickness must be non-negative")
435
-
436
- pwht_required = False
437
- reasons = []
438
- temp_range = ""
439
- hold_time = ""
440
-
441
- if material_group == "P1":
442
- if thickness_in_in > 1.25:
443
- pwht_required = True
444
- reasons.append("Thickness > 1.25 inches (UCS-56 guidance).")
445
- temp_range = "1100°F - 1200°F"
446
- hold_time = f"Minimum {max(1, int(math.ceil(thickness_in_in)))} hours (approx)"
447
- if carbon_equivalent > 0.35:
448
- pwht_required = True
449
- reasons.append("Carbon equivalent > 0.35% (UCS-56).")
450
- elif material_group == "P8":
451
- reasons.append("Stainless steel (P8): PWHT generally not required; follow spec.")
452
  else:
453
- reasons.append("Material group unknown: consult specification.")
454
-
455
- if not pwht_required:
456
- reasons.append("Below thickness/CE limits for mandatory PWHT (preliminary check).")
457
 
458
- return {"pwht_required": pwht_required, "reasons": reasons, "temperature_range": temp_range, "hold_time": hold_time}
459
-
460
-
461
- def _approximate_rated_mdmt(material_group: str, thickness_in_in: float) -> float:
462
- """
463
- APPROXIMATION: Provide a conservative approximate rated MDMT based on material group and thickness.
464
- This is NOT the full UCS-66 lookup. Use as a placeholder to compare with Design MDMT.
465
- We define a simple table for P1 and P8 (°F).
466
- """
467
- if material_group == "P8":
468
- return -325.0
469
- if thickness_in_in <= 0.5:
470
- return 60.0
471
- if thickness_in_in <= 1.0:
472
- return 30.0
473
- if thickness_in_in <= 2.0:
474
- return 0.0
475
- if thickness_in_in <= 4.0:
476
- return -20.0
477
- return -50.0
478
-
479
-
480
- def determine_impact_test_requirements(material_group: str, design_mdmt: float, thickness_in_in: float, units: str) -> Dict[str, Any]:
481
  """
482
- Compare Design MDMT (user input) with approximate Rated MDMT.
483
- Returns whether impact test required and test temp (Design MDMT - 30°F or -17°C).
484
- Units: 'USC' or 'SI' for comparisons and for returned values.
 
 
 
 
 
 
485
  """
486
- if thickness_in_in < 0:
487
- raise ValueError("Thickness must be non-negative")
488
- if units not in ("USC", "SI"):
489
- raise ValueError("units must be 'USC' or 'SI'")
490
-
491
- # Work in °F internally for rated_mdmt approximation
492
- if units == "SI":
493
- design_mdmt_f = convert_temp(design_mdmt, "C", "F")
494
  else:
495
- design_mdmt_f = design_mdmt
496
-
497
- rated_mdmt_f = _approximate_rated_mdmt(material_group, thickness_in_in)
498
-
499
- # Coincident ratio: ASSUMPTION - we use a simple rule: if design_temp within 30°F of rated, may be allowed by coincident rules
500
- coincident_info = "Coincident ratio not fully implemented; conservative compare used. See UCS-66 for full logic."
501
- impact_required = design_mdmt_f > rated_mdmt_f # if design is colder (lower), fine; if warmer (higher) -> required
502
- # test temperature suggestion
503
- if units == "SI":
504
- test_temp = convert_temp(design_mdmt_f - 30.0, "F", "C")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  else:
506
- test_temp = design_mdmt_f - 30.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
 
508
- return {
509
- "impact_test_required": bool(impact_required),
510
- "rated_mdmt": rated_mdmt_f if units == "USC" else convert_temp(rated_mdmt_f, "F", "C"),
511
- "design_mdmt": design_mdmt,
512
- "exemption_coincident_ratio_info": coincident_info,
513
- "test_temperature": test_temp,
514
- "notes": "APPROXIMATION: rated MDMT is an estimate - use UCS-66 tables for precise determination.",
515
- }
516
 
 
 
 
 
 
 
 
 
 
 
517
 
518
- 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]:
519
- """
520
- Inverse of thickness formulas to compute MAWP.
521
- If component_type == 'shell', use P = (SE t) / (R + 0.6 t)
522
- For heads, approximate using ellipsoidal style.
523
- """
524
- if t <= 0:
525
- raise ValueError("Thickness must be positive")
526
- if R_or_D <= 0:
527
- raise ValueError("R_or_D must be positive")
528
- if S <= 0:
529
- raise ValueError("Allowable stress must be positive")
530
- if not (0 < E <= 1):
531
- raise ValueError("Joint efficiency E must be in (0,1]")
532
 
533
- if component_type == "shell":
534
- P = (S * E * t) / (R_or_D + 0.6 * t)
535
- formula = "P = SE*t/(R + 0.6t)"
536
- else:
537
- # approximate head MAWP (ellipsoidal form)
538
- if head_type is None:
539
- head_type = "ellipsoidal"
540
- if head_type == "ellipsoidal":
541
- K = 1.0
542
- P = (2 * S * E * t * K) / (R_or_D + 0.2 * t)
543
- formula = "P = 2SEtK/(D + 0.2t)"
544
- elif head_type == "hemispherical":
545
- P = (2 * S * E * t) / (R_or_D + 0.4 * t)
546
- formula = "P = 2SEt/(R + 0.4t)"
547
- else:
548
- # fallback
549
- P = (2 * S * E * t) / (R_or_D + 0.2 * t)
550
- formula = "P = 2SEt/(D + 0.2t)"
551
-
552
- return {"mawp": float(P), "formula": formula}
553
-
554
-
555
- # ---------------------------
556
- # Streamlit UI
557
- # ---------------------------
558
-
559
- def init_session_state_defaults():
560
- """Initialize session defaults for inputs."""
561
- defaults = {
562
- "unit_system": "USC",
563
- "design_pressure": 150.0,
564
- "design_temperature": 200.0,
565
- "design_mdmt": -20.0,
566
- "corrosion_allowance": DEFAULT_CORROSION_ALLOWANCE_IN,
567
- "inside_diameter": 60.0,
568
- "head_type": "ellipsoidal",
569
- "joint_efficiency": 1.0,
570
- "material_category": "Carbon Steel",
571
- "material_grade": "SA-516-70",
572
- "user_defined_allowable": 15000.0,
573
- "nozzle_opening_diameter": 6.0,
574
- "nozzle_wall_thickness": 0.5,
575
- "use_reinforcing_pad": False,
576
- "pad_thickness": 0.25,
577
- "pad_width": 8.0,
578
- "shell_local_extra_thickness": 0.0,
579
- "weld_throat": 0.1,
580
- "weld_efficiency": 0.7,
581
- "calc_atm_shell": False,
582
- "asme_edition": SUPPORTED_ASME_EDITIONS[0],
583
- }
584
- for k, v in defaults.items():
585
- if k not in st.session_state:
586
- st.session_state[k] = v
587
-
588
-
589
- def reset_session_state():
590
- keys_to_remove = [
591
- "last_results",
592
- "warnings",
593
- "errors",
594
- ]
595
- for k in keys_to_remove:
596
- if k in st.session_state:
597
- del st.session_state[k]
598
- # Reset inputs to defaults
599
- init_session_state_defaults()
600
-
601
-
602
- def run_calculations(inputs: Dict[str, Any]) -> Dict[str, Any]:
603
- """
604
- Orchestrate reads, computes and returns a results dict.
605
- Returns also warnings and any messages.
606
- """
607
- # Validate key inputs
608
- errors = []
609
- warnings = []
610
-
611
- unit_system = inputs["unit_system"]
612
- # Material handling
613
- mat_cat = inputs["material_category"]
614
- mat_grade = inputs["material_grade"]
615
-
616
- # Allowable stress S
617
- if mat_cat == "User Defined":
618
- S = inputs.get("user_defined_allowable")
619
- if S is None or S <= 0:
620
- raise ValueError("User-defined allowable stress must be a positive number")
621
- mg = inputs.get("user_defined_group", "P1")
622
- ce = inputs.get("user_defined_ce", 0.30)
623
- is_pipe_grade = False
624
- else:
625
- S, wmsg = get_allowable_stress(mat_cat, mat_grade, inputs["design_temperature"], "USC" if unit_system == "USC" else "SI")
626
- if wmsg:
627
- warnings.append(wmsg)
628
- mg = MATERIALS[mat_cat][mat_grade]["material_group"]
629
- ce = MATERIALS[mat_cat][mat_grade]["carbon_equivalent"]
630
- is_pipe_grade = MATERIALS[mat_cat][mat_grade].get("is_pipe_grade", False)
631
-
632
- # Geometry conversions: unify units so functions get consistent units
633
- # In this implementation we expect S and P to be in consistent units already (controller)
634
- P = inputs["design_pressure"]
635
- D = inputs["inside_diameter"]
636
- R = D / 2.0
637
- E = inputs["joint_efficiency"]
638
- ca = inputs["corrosion_allowance"]
639
-
640
- # Compute shell thicknesses
641
- shell = calculate_shell_thickness(P, R, S, E)
642
- shell_required = shell["required_thickness"]
643
- # Add corrosion allowance to total shell thickness
644
- total_shell = shell_required + ca
645
-
646
- # Optionally compute atmospheric shell
647
- atm_shell = None
648
- if inputs.get("calc_atm_shell"):
649
- atm_shell = {"note": "Atmospheric shell check requested: with P=0 this calculation is geometry-specific. Provide loading (wind, vacuum) for meaningful thickness check."}
650
-
651
- # Head thickness
652
- head = calculate_head_thickness(P, D, S, E, inputs["head_type"])
653
- head_required = head["required_thickness"]
654
- total_head = head_required + ca
655
-
656
- # Nozzle reinforcement (improved)
657
- nozzle = calculate_nozzle_reinforcement(
658
- inputs["nozzle_opening_diameter"],
659
- total_shell,
660
- inputs["nozzle_wall_thickness"],
661
- inputs["pad_thickness"] if inputs["use_reinforcing_pad"] else 0.0,
662
- inputs["pad_width"] if inputs["use_reinforcing_pad"] else 0.0,
663
- mat_grade if mat_cat != "User Defined" else None,
664
- shell_local_extra_thickness=inputs.get("shell_local_extra_thickness", 0.0),
665
- weld_throat=inputs.get("weld_throat", 0.0),
666
- weld_efficiency=inputs.get("weld_efficiency", 0.7),
667
- )
668
-
669
- # For pipe grades, mention 12.5% reduction in shell effective thickness for reinforcement checks
670
- if is_pipe_grade:
671
- reduced_shell_effective = total_shell * (1.0 - PIPE_THICKNESS_REDUCTION)
672
- warnings.append(f"Pipe grade detected; applied {PIPE_THICKNESS_REDUCTION*100:.1f}% reduction to effective shell thickness for reinforcement checks: {reduced_shell_effective:.4f}")
673
- else:
674
- reduced_shell_effective = total_shell
675
 
676
- # PWHT
677
- if unit_system == "SI":
678
- thickness_in_in = total_shell / 25.4
679
- else:
680
- thickness_in_in = total_shell
681
-
682
- pwht = determine_pwht_requirements(mg, thickness_in_in, ce)
683
-
684
- # Impact test / MDMT
685
- design_mdmt = inputs["design_mdmt"]
686
- impact = determine_impact_test_requirements(mg, design_mdmt, thickness_in_in, unit_system)
687
-
688
- # MAWPs
689
- shell_mawp = calculate_mawp_from_thickness(total_shell, R, S, E, "shell")
690
- head_mawp = calculate_mawp_from_thickness(total_head, D, S, E, "head", inputs["head_type"])
691
- governing_mawp = min(shell_mawp["mawp"], head_mawp["mawp"])
692
-
693
- results = {
694
- "shell": shell,
695
- "total_shell_thickness": total_shell,
696
- "head": head,
697
- "total_head_thickness": total_head,
698
- "nozzle": nozzle,
699
- "pwht": pwht,
700
- "impact": impact,
701
- "shell_mawp": shell_mawp,
702
- "head_mawp": head_mawp,
703
- "governing_mawp": governing_mawp,
704
- "reduced_shell_effective": reduced_shell_effective,
705
- "warnings": warnings,
706
- }
707
- return results
708
-
709
-
710
- # ---------------------------
711
- # Streamlit app layout
712
- # ---------------------------
713
-
714
- def main():
715
- st.set_page_config(page_title="ASME Section VIII Calculator", page_icon="🔧", layout="wide")
716
- init_session_state_defaults()
717
-
718
- st.markdown("<h2 style='color:#0b5fff'>🔧 ASME Section VIII — Preliminary Calculator</h2>", unsafe_allow_html=True)
719
- 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.")
720
- st.sidebar.title("Inputs")
721
-
722
- # Unit system
723
- unit_system = st.sidebar.selectbox("Unit System", ["USC", "SI"], index=0 if st.session_state["unit_system"] == "USC" else 1)
724
- st.session_state["unit_system"] = unit_system
725
-
726
- # ASME edition placeholder
727
- st.session_state["asme_edition"] = st.sidebar.selectbox("ASME Section VIII Edition", SUPPORTED_ASME_EDITIONS, index=SUPPORTED_ASME_EDITIONS.index(st.session_state.get("asme_edition", SUPPORTED_ASME_EDITIONS[0])))
728
-
729
- # Basic design
730
- pressure_label = "Design Pressure (psi)" if unit_system == "USC" else "Design Pressure (bar)"
731
- temp_label = "Design Temperature (°F)" if unit_system == "USC" else "Design Temperature (°C)"
732
- mdmt_label = "Design MDMT (°F)" if unit_system == "USC" else "Design MDMT (°C)"
733
- length_label = "Inside Diameter (in)" if unit_system == "USC" else "Inside Diameter (mm)"
734
- ca_label = "Corrosion Allowance (in)" if unit_system == "USC" else "Corrosion Allowance (mm)"
735
-
736
- st.session_state["design_pressure"] = st.sidebar.number_input(pressure_label, value=float(st.session_state["design_pressure"]))
737
- st.session_state["design_temperature"] = st.sidebar.number_input(temp_label, value=float(st.session_state["design_temperature"]))
738
- st.session_state["design_mdmt"] = st.sidebar.number_input(mdmt_label, value=float(st.session_state["design_mdmt"]))
739
- st.session_state["corrosion_allowance"] = st.sidebar.number_input(ca_label, value=float(st.session_state["corrosion_allowance"]))
740
-
741
- st.sidebar.markdown("---")
742
- st.session_state["inside_diameter"] = st.sidebar.number_input(length_label, value=float(st.session_state["inside_diameter"]))
743
- st.session_state["head_type"] = st.sidebar.selectbox("Head Type", ["ellipsoidal", "torispherical", "hemispherical"], index=0 if st.session_state["head_type"] == "ellipsoidal" else 0)
744
- # Added E = 0.7 option as requested
745
- st.session_state["joint_efficiency"] = st.sidebar.selectbox("Joint Efficiency (E)", [1.0, 0.95, 0.9, 0.85, 0.7], index=[1.0,0.95,0.9,0.85,0.7].index(st.session_state.get("joint_efficiency", 1.0)))
746
-
747
- st.sidebar.markdown("---")
748
- st.session_state["material_category"] = st.sidebar.selectbox("Material Category", ["Carbon Steel", "Stainless Steel", "User Defined"], index=0)
749
- # material grade selection
750
- if st.session_state["material_category"] == "User Defined":
751
- st.session_state["material_grade"] = st.sidebar.text_input("Material Grade (user)", value="USER-MAT")
752
- 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"]))
753
- st.session_state["user_defined_group"] = st.sidebar.selectbox("User-defined material group", ["P1", "P8"], index=0)
754
- st.session_state["user_defined_ce"] = st.sidebar.number_input("User-defined carbon equivalent (if known)", value=0.30)
755
  else:
756
- grades = list(MATERIALS[st.session_state["material_category"]].keys())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757
  try:
758
- idx = grades.index(st.session_state["material_grade"])
759
- except Exception:
760
- idx = 0
761
- st.session_state["material_grade"] = grades[0]
762
- st.session_state["material_grade"] = st.sidebar.selectbox("Material Grade", grades, index=idx)
763
-
764
- st.sidebar.markdown("---")
765
- 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"]))
766
- st.session_state["nozzle_wall_thickness"] = st.sidebar.number_input("Nozzle Wall Thickness (same unit)", value=float(st.session_state["nozzle_wall_thickness"]))
767
- st.session_state["use_reinforcing_pad"] = st.sidebar.checkbox("Use Reinforcing Pad", value=st.session_state["use_reinforcing_pad"])
768
- if st.session_state["use_reinforcing_pad"]:
769
- st.session_state["pad_thickness"] = st.sidebar.number_input("Pad Thickness (same unit)", value=float(st.session_state["pad_thickness"]))
770
- st.session_state["pad_width"] = st.sidebar.number_input("Pad Width (same unit)", value=float(st.session_state["pad_width"]))
771
-
772
- # New inputs for improved nozzle reinforcement
773
- st.sidebar.markdown("---")
774
- st.session_state["shell_local_extra_thickness"] = st.sidebar.number_input("Local shell extra thickness (for reinforcement) (same unit)", value=float(st.session_state.get("shell_local_extra_thickness", 0.0)))
775
- st.session_state["weld_throat"] = st.sidebar.number_input("Effective weld throat (same unit) for weld contribution", value=float(st.session_state.get("weld_throat", 0.1)))
776
- st.session_state["weld_efficiency"] = st.sidebar.slider("Weld efficiency factor", min_value=0.0, max_value=1.0, value=float(st.session_state.get("weld_efficiency", 0.7)), step=0.05)
777
-
778
- st.sidebar.markdown("---")
779
- st.session_state["calc_atm_shell"] = st.sidebar.checkbox("Also calculate shell at atmospheric pressure", value=st.session_state["calc_atm_shell"])
780
-
781
- # Buttons
782
- col1, col2 = st.columns([1, 1])
783
- run_calc = col1.button("Calculate")
784
- reset_btn = col2.button("Reset to defaults")
785
-
786
- if reset_btn:
787
- reset_session_state()
788
- st.experimental_rerun()
789
-
790
- # Prepare inputs dict
791
- inputs = {
792
- "unit_system": st.session_state["unit_system"],
793
- "design_pressure": float(st.session_state["design_pressure"]),
794
- "design_temperature": float(st.session_state["design_temperature"]),
795
- "design_mdmt": float(st.session_state["design_mdmt"]),
796
- "corrosion_allowance": float(st.session_state["corrosion_allowance"]),
797
- "inside_diameter": float(st.session_state["inside_diameter"]),
798
- "head_type": st.session_state["head_type"],
799
- "joint_efficiency": float(st.session_state["joint_efficiency"]),
800
- "material_category": st.session_state["material_category"],
801
- "material_grade": st.session_state["material_grade"],
802
- "user_defined_allowable": float(st.session_state.get("user_defined_allowable", 0.0)),
803
- "user_defined_group": st.session_state.get("user_defined_group", "P1"),
804
- "user_defined_ce": float(st.session_state.get("user_defined_ce", 0.30)),
805
- "nozzle_opening_diameter": float(st.session_state["nozzle_opening_diameter"]),
806
- "nozzle_wall_thickness": float(st.session_state["nozzle_wall_thickness"]),
807
- "use_reinforcing_pad": bool(st.session_state["use_reinforcing_pad"]),
808
- "pad_thickness": float(st.session_state.get("pad_thickness", 0.0)),
809
- "pad_width": float(st.session_state.get("pad_width", 0.0)),
810
- "shell_local_extra_thickness": float(st.session_state.get("shell_local_extra_thickness", 0.0)),
811
- "weld_throat": float(st.session_state.get("weld_throat", 0.1)),
812
- "weld_efficiency": float(st.session_state.get("weld_efficiency", 0.7)),
813
- "calc_atm_shell": bool(st.session_state.get("calc_atm_shell", False)),
814
- }
815
-
816
- # Run calculations only when requested
817
- if run_calc:
818
  try:
819
- results = run_calculations(inputs)
820
- st.session_state["last_results"] = results
821
- st.session_state["warnings"] = results.get("warnings", [])
822
- st.success("Calculations completed.")
823
- except Exception as e:
824
- st.session_state["errors"] = str(e)
825
- st.error(f"Error during calculation: {e}")
826
-
827
- # show warnings/errors if present
828
- if "warnings" in st.session_state and st.session_state["warnings"]:
829
- for w in st.session_state["warnings"]:
830
- st.warning(w)
831
- if "errors" in st.session_state:
832
- st.error(st.session_state["errors"])
833
-
834
- # Tabs for outputs
835
- tab1, tab2, tab3, tab4, tab5 = st.tabs(["Shell", "Head", "Nozzle", "PWHT & Impact", "Summary"])
836
-
837
- results = st.session_state.get("last_results")
838
-
839
- with tab1:
840
- st.header("Shell Thickness (UG-27)")
841
- if results:
842
- shell = results["shell"]
843
- st.write("**Formula used:**", shell["formula_used"])
844
- st.write("**Condition:**", shell["condition_check"])
845
- units_len = "in" if unit_system == "USC" else "mm"
846
- st.metric("Circumferential thickness", f"{format_value(shell['circumferential_thickness'], units_len, True)} {units_len}")
847
- st.metric("Longitudinal thickness", f"{format_value(shell['longitudinal_thickness'], units_len, True)} {units_len}")
848
- st.metric("Required thickness (governing)", f"{format_value(shell['required_thickness'], units_len, True)} {units_len}")
849
- st.markdown("**Total shell thickness including corrosion allowance:**")
850
- st.write(f"{format_value(results['total_shell_thickness'], units_len, True)} {units_len}")
851
-
852
- with st.expander("Show step-by-step shell calculations"):
853
- 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']}")
854
- st.write(shell)
855
- else:
856
- st.info("Press **Calculate** to run shell thickness calculations.")
857
-
858
- with tab2:
859
- st.header("Head Thickness (UG-32)")
860
- if results:
861
- head = results["head"]
862
- units_len = "in" if unit_system == "USC" else "mm"
863
- st.write("**Head Type:**", head["head_type"].title())
864
- st.write("**Formula used:**", head["formula_used"])
865
- st.metric("Required head thickness", f"{format_value(results['head']['required_thickness'], units_len, True)} {units_len}")
866
- st.write("Total head thickness including corrosion allowance:", f"{format_value(results['total_head_thickness'], units_len, True)} {units_len}")
867
- with st.expander("Show step-by-step head calculations"):
868
- st.write(head["intermediate_values"])
869
- else:
870
- st.info("Press **Calculate** to run head thickness calculations.")
871
-
872
- with tab3:
873
- st.header("Nozzle Reinforcement (UG-37 approx - improved)")
874
- st.info("Provide nozzle geometry inputs below. The method is a conservative projection-area approximation; see assumptions in the result.")
875
- if results:
876
- nozzle = results["nozzle"]
877
- units_len = "in" if unit_system == "USC" else "mm"
878
- st.write("Assumptions:")
879
- for a in nozzle["assumptions"]:
880
- st.write("-", a)
881
- st.write("Required area (A_required = d_opening * t_shell):", f"{format_value(nozzle['area_required'], units_len)} {units_len}^2")
882
- st.write("Shell local contribution (A_shell_local):", f"{format_value(nozzle['area_shell_local'], units_len)} {units_len}^2")
883
- st.write("Nozzle contribution (A_nozzle = pi*d*t_nozzle):", f"{format_value(nozzle['area_nozzle'], units_len)} {units_len}^2")
884
- st.write("Pad contribution (A_pad):", f"{format_value(nozzle['area_pad'], units_len)} {units_len}^2")
885
- st.write("Weld contribution (A_weld):", f"{format_value(nozzle['area_weld'], units_len)} {units_len}^2")
886
- st.write("Total available area:", f"{format_value(nozzle['area_available_total'], units_len)} {units_len}^2")
887
- if nozzle["reinforcement_adequate"]:
888
- st.success(f"Reinforcement adequate (SF = {nozzle['safety_factor']:.2f})")
889
- else:
890
- st.error(f"Reinforcement inadequate (SF = {nozzle['safety_factor']:.2f})")
891
- with st.expander("Show step-by-step nozzle calculation (raw values)"):
892
- st.write(nozzle)
893
- else:
894
- st.info("Enter inputs and press **Calculate** to run nozzle reinforcement check.")
895
-
896
- with tab4:
897
- st.header("PWHT & Impact Test")
898
- if results:
899
- pwht = results["pwht"]
900
- impact = results["impact"]
901
-
902
- st.subheader("PWHT")
903
- if pwht["pwht_required"]:
904
- st.error("PWHT REQUIRED")
905
- else:
906
- st.success("PWHT not required by preliminary check")
907
- st.write("Reasons:")
908
- for r in pwht["reasons"]:
909
- st.write("-", r)
910
- if pwht["temperature_range"]:
911
- st.write("Suggested PWHT temperature range:", pwht["temperature_range"])
912
- st.write("Suggested hold time:", pwht["hold_time"])
913
-
914
- st.subheader("Impact Test (MDMT check)")
915
- rated = impact["rated_mdmt"]
916
- design = impact["design_mdmt"]
917
- if unit_system == "SI":
918
- st.write("Rated MDMT (°C):", f"{format_value(rated, 'C')} °C")
919
- st.write("Design MDMT (°C):", f"{format_value(design, 'C')} °C")
920
  else:
921
- st.write("Rated MDMT (°F):", f"{format_value(rated, 'F')} °F")
922
- st.write("Design MDMT (°F):", f"{format_value(design, 'F')} °F")
 
923
 
924
- if impact["impact_test_required"]:
925
- st.error("Impact test required — Charpy V-notch")
926
- st.write("Suggested test temperature:", f"{impact['test_temperature']:.1f} {'°C' if unit_system=='SI' else '°F'}")
927
- else:
928
- st.success("Impact test not required by preliminary check")
929
- st.write("Notes:", impact["notes"])
930
- with st.expander("Show step-by-step MDMT logic and notes"):
931
- st.write(impact)
932
- else:
933
- st.info("Press **Calculate** to run PWHT and impact checks.")
934
-
935
- with tab5:
936
- st.header("Summary")
937
- if results:
938
- # Build DataFrame summary
939
- data = {
940
- "Parameter": [
941
- "Design Pressure",
942
- "Design Temperature",
943
- "Design MDMT",
944
- "Inside Diameter",
945
- "Material",
946
- "Allowable Stress",
947
- "Joint Efficiency",
948
- "Corrosion Allowance",
949
- "Shell Required Thickness",
950
- "Shell Total Thickness",
951
- "Head Required Thickness",
952
- "Head Total Thickness",
953
- "Governing MAWP",
954
- ],
955
- "Value": []
956
  }
957
- # allowable stress fetch (again) but safe
958
- if inputs["material_category"] == "User Defined":
959
- allowable = inputs["user_defined_allowable"]
960
- else:
961
- allowable, _ = get_allowable_stress(inputs["material_category"], inputs["material_grade"], inputs["design_temperature"], "USC" if unit_system == "USC" else "SI")
962
- units_len = "in" if unit_system == "USC" else "mm"
963
- units_pres = "psi" if unit_system == "USC" else "MPa"
964
- data["Value"] = [
965
- f"{inputs['design_pressure']} {'psi' if unit_system=='USC' else 'bar'}",
966
- f"{inputs['design_temperature']} {'°F' if unit_system=='USC' else '°C'}",
967
- f"{inputs['design_mdmt']} {'°F' if unit_system=='USC' else '°C'}",
968
- f"{inputs['inside_diameter']} {units_len}",
969
- f"{inputs['material_grade']}",
970
- f"{format_value(allowable, units_pres)} {units_pres}",
971
- f"{inputs['joint_efficiency']}",
972
- f"{inputs['corrosion_allowance']} {units_len}",
973
- f"{format_value(results['shell']['required_thickness'], units_len, True)} {units_len}",
974
- f"{format_value(results['total_shell_thickness'], units_len, True)} {units_len}",
975
- f"{format_value(results['head']['required_thickness'], units_len, True)} {units_len}",
976
- f"{format_value(results['total_head_thickness'], units_len, True)} {units_len}",
977
- f"{format_value(results['governing_mawp'], 'psi' if unit_system=='USC' else 'bar')} {'psi' if unit_system=='USC' else 'bar'}",
978
- ]
979
- df = pd.DataFrame(data)
980
- st.dataframe(df, use_container_width=True)
981
- st.write("Report generated:", datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
982
-
983
- # Exports
984
- csv = df.to_csv(index=False).encode("utf-8")
985
- st.download_button("Download Summary CSV", csv, file_name="asme_summary.csv", mime="text/csv")
986
-
987
- # PDF export (optional)
988
- try:
989
- from reportlab.lib.pagesizes import letter
990
- from reportlab.pdfgen import canvas
991
- # create PDF in-memory
992
- from io import BytesIO
993
- buffer = BytesIO()
994
- c = canvas.Canvas(buffer, pagesize=letter)
995
- c.setFont("Helvetica", 12)
996
- c.drawString(30, 750, "ASME Section VIII — Preliminary Summary Report")
997
- y = 730
998
- for i, (param, val) in df.iterrows():
999
- c.drawString(30, y, f"{val['Parameter']}: {val['Value']}")
1000
- y -= 15
1001
- if y < 50:
1002
- c.showPage()
1003
- y = 750
1004
- c.drawString(30, y - 20, f"Generated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
1005
- c.save()
1006
- pdf_data = buffer.getvalue()
1007
- buffer.close()
1008
- st.download_button("Download Summary PDF", pdf_data, file_name="asme_summary.pdf", mime="application/pdf")
1009
- except Exception:
1010
- st.info("PDF export requires optional package 'reportlab'. Install to enable PDF export.")
1011
-
1012
  else:
1013
- st.info("Press **Calculate** to produce a summary.")
1014
-
1015
- # Footer / disclaimer
1016
- st.markdown("---")
1017
- st.caption("Disclaimer: This app provides preliminary calculations only. Verify final design with a licensed professional engineer and use the latest ASME code edition.")
1018
-
1019
- if __name__ == "__main__":
1020
- main()
 
 
 
1
  #!/usr/bin/env python3
2
  """
3
+ ASME Calculator (updated)add Unit System (USC / SI) support.
4
+
5
+ Notes:
6
+ - Calculation functions assume consistent units (i.e. pressure and allowable stress in matching systems;
7
+ lengths all in the same linear unit). This app ensures the user supplies values in a single system.
8
+ - If you want the app to accept mixed units and convert internally, we can add that later.
 
 
 
 
 
 
 
 
 
 
 
9
  """
10
 
11
  from __future__ import annotations
12
+ import os
13
  import math
14
  import datetime
15
+ from typing import Dict, Any
16
 
 
 
17
  import streamlit as st
18
+ import pandas as pd
19
+ import numpy as np
20
 
21
+ # PDF generator (fpdf2)
22
+ from fpdf import FPDF
23
+
24
+ # Groq client (optional - requires GROQ_API_KEY in environment or HF secrets)
25
+ try:
26
+ from groq import Groq
27
+ except Exception:
28
+ Groq = None # groq may not be installed or env var not set
29
+
30
+ # ========== PAGE CONFIG ==========
31
+ st.set_page_config(page_title="ASME Calculator", layout="wide")
32
+ st.title("🛠️ ASME CALCULATOR")
33
+ st.caption("Preliminary ASME Section VIII calculations. Verify with a licensed engineer and latest ASME edition.")
34
+
35
+ # ========== API CLIENT ==========
36
+ groq_api_key = os.getenv("GROQ_API_KEY")
37
+ groq_client = Groq(api_key=groq_api_key) if (Groq is not None and groq_api_key) else None
38
+
39
+ # ========== PDF GENERATOR ==========
40
+ class PDF(FPDF):
41
+ def header(self):
42
+ self.set_font("Helvetica", "B", 14)
43
+ self.cell(0, 10, "ASME VIII Div.1 Vessel Design Report", 0, 1, "C")
44
+
45
+ def chapter_title(self, title):
46
+ self.set_font("Helvetica", "B", 12)
47
+ self.cell(0, 10, title, 0, 1, "L")
48
+
49
+ def chapter_body(self, body):
50
+ self.set_font("Helvetica", "", 11)
51
+ self.multi_cell(0, 8, body)
52
+
53
+ # ========== UNIT HELPERS ==========
54
+ # Conversion factors
55
+ MPA_TO_PSI = 145.037737797 # 1 MPa = 145.0377 psi
56
+ MM_TO_IN = 0.03937007874015748 # 1 mm = 0.0393701 in
57
+ IN_TO_MM = 25.4
58
+ PSI_TO_MPA = 1.0 / MPA_TO_PSI
59
+
60
+ def mpato_psi(x: float) -> float:
61
+ return x * MPA_TO_PSI
62
+
63
+ def psi_to_mpa(x: float) -> float:
64
+ return x * PSI_TO_MPA
65
+
66
+ def mm_to_in(x: float) -> float:
67
+ return x * MM_TO_IN
68
+
69
+ def in_to_mm(x: float) -> float:
70
+ return x * IN_TO_MM
71
+
72
+ # ========== CALCULATION FUNCTIONS ==========
73
+ def shell_thickness(P: float, R: float, S: float, E: float, corrosion: float) -> float:
74
+ """Return governing shell thickness (uses circumferential formula as originally)."""
75
+ # Validation (basic)
76
+ if E <= 0 or E > 1:
77
+ raise ValueError("Joint efficiency E must be in (0,1].")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  if S <= 0:
79
+ raise ValueError("Allowable stress must be positive.")
80
+ # Use the formula as provided originally (assumes P, R, S in consistent units)
81
+ denom = S * E - 0.6 * P
82
+ if denom <= 0:
83
+ # Avoid division by zero; return a large thickness suggestion or raise
84
+ raise ValueError("Denominator (S*E - 0.6*P) <= 0. Check input values (pressure too high or S/E too low).")
85
+ t = (P * R) / denom
86
+ return t + corrosion
87
+
88
+ def head_thickness(P: float, R: float, S: float, E: float, corrosion: float, head_type: str) -> float:
89
+ """Return head thickness per simplified formulas (unit-consistent)."""
90
+ if E <= 0 or E > 1:
91
+ raise ValueError("Joint efficiency E must be in (0,1].")
92
+ if head_type == "Ellipsoidal":
93
+ denom = S * E - 0.1 * P
94
+ if denom <= 0:
95
+ raise ValueError("Denominator invalid for Ellipsoidal head formula.")
96
+ return (0.5 * P * R) / denom + corrosion
97
+ elif head_type == "Torispherical":
98
+ denom = S * E - 0.1 * P
99
+ if denom <= 0:
100
+ raise ValueError("Denominator invalid for Torispherical head formula.")
101
+ return (0.885 * P * R) / denom + corrosion
102
+ elif head_type == "Hemispherical":
103
+ denom = 2 * S * E - 0.2 * P
104
+ if denom <= 0:
105
+ raise ValueError("Denominator invalid for Hemispherical head formula.")
106
+ return (P * R) / denom + corrosion
107
  else:
108
+ raise ValueError("Unsupported head type.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
+ def nozzle_reinforcement(P: float, d: float, t_shell: float, t_nozzle: float, S: float, E: float) -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  """
112
+ Simplified conservative check originally provided:
113
+ (P * d) / (2 * S * E) <= (t_shell + t_nozzle)
114
+ Works if units are consistent.
 
 
 
 
 
 
 
 
115
  """
116
+ lhs = (P * d) / (2 * S * E)
117
+ rhs = (t_shell + t_nozzle)
118
+ return lhs <= rhs
119
+
120
+ def pwht_required(thickness: float, material: str = "CS", unit_system: str = "SI") -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  """
122
+ Very simple rule kept from original:
123
+ In original code thickness threshold 38 (assumed mm). We'll adapt threshold by unit system:
124
+ - SI: 38 mm threshold (original)
125
+ - USC: convert 38 mm to inches (~1.496 in)
126
  """
127
+ if unit_system == "SI":
128
+ threshold = 38.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  else:
130
+ threshold = mm_to_in(38.0)
131
+ return (material == "CS") and (thickness > threshold)
 
 
132
 
133
+ def impact_test_required(thickness: float, MDMT: float = -20.0, material: str = "CS", unit_system: str = "SI") -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  """
135
+ Original logic was nonspecific; we keep the original behaviour but map units consistently.
136
+ The original check: material == CS and (MDMT < -29 and thickness > 12)
137
+ - 12 assumed mm in original code.
138
+ We'll adapt thresholds per unit_system:
139
+ - SI: thickness threshold = 12 mm (original)
140
+ - USC: convert 12 mm to inches (~0.4724 in)
141
+ - MDMT comparisons assumed in °C in original? Original used -29 which looks like °C (or °F?). The original code
142
+ mixed units; we'll treat MDMT input as per user's unit system and compare raw values exactly as original.
143
+ WARNING: This is a placeholder approximation. For real MDMT logic use UCS-66 tables.
144
  """
145
+ if unit_system == "SI":
146
+ thickness_threshold = 12.0 # mm
147
+ mdmt_check_val = -29.0
 
 
 
 
 
148
  else:
149
+ thickness_threshold = mm_to_in(12.0)
150
+ mdmt_check_val = -29.0 # user will supply °F when using USC; this retains same numeric test as original.
151
+ return (material == "CS") and (MDMT < mdmt_check_val and thickness > thickness_threshold)
152
+
153
+ # ========== SESSION STATE ==========
154
+ if "run_done" not in st.session_state:
155
+ st.session_state.run_done = False
156
+ if "ai_done" not in st.session_state:
157
+ st.session_state.ai_done = False
158
+
159
+ # ========== SIDEBAR INPUTS ==========
160
+ with st.sidebar.expander("📥 Manual Design Inputs", expanded=True):
161
+ # Unit System selection (new)
162
+ unit_system = st.radio("Unit System:", ["SI (MPa / mm)", "USC (psi / in)"], index=0)
163
+ use_si = unit_system.startswith("SI")
164
+
165
+ input_mode = st.radio("Input Mode:", ["Manual Entry", "Upload CSV"])
166
+ run_calculation = False
167
+
168
+ # Set sensible defaults depending on unit system (based on previous defaults)
169
+ if use_si:
170
+ default_P = 2.0 # MPa (approx previous)
171
+ default_R = 1000.0 # mm (previous)
172
+ default_S = 120.0 # MPa (previous)
173
+ default_corrosion = 1.5 # mm
174
+ default_d_nozzle = 200.0 # mm
175
+ default_t_shell = 12.0 # mm
176
+ default_t_nozzle = 10.0 # mm
177
+ default_thickness = 40.0 # mm
178
+ default_mdmt = -20.0 # °C
179
  else:
180
+ # convert defaults to USC
181
+ default_P = mpato_psi(2.0) # psi
182
+ default_R = mm_to_in(1000.0) # in
183
+ default_S = mpato_psi(120.0) # psi
184
+ default_corrosion = mm_to_in(1.5) # in
185
+ default_d_nozzle = mm_to_in(200.0) # in
186
+ default_t_shell = mm_to_in(12.0) # in
187
+ default_t_nozzle = mm_to_in(10.0) # in
188
+ default_thickness = mm_to_in(40.0) # in
189
+ default_mdmt = mm_to_in(-20.0) if False else -20.0 # MDMT keep numeric, user interprets units (°F)
190
+
191
+ if input_mode == "Manual Entry":
192
+ # Labels and formats adjusted per unit system
193
+ if use_si:
194
+ P = st.number_input("Design Pressure (MPa)", value=float(default_P), format="%.3f")
195
+ R = st.number_input("Internal Radius (mm)", value=float(default_R), format="%.2f")
196
+ S = st.number_input("Allowable Stress (MPa)", value=float(default_S), format="%.2f")
197
+ corrosion = st.number_input("Corrosion Allowance (mm)", value=float(default_corrosion), format="%.2f")
198
+ mdmt = st.number_input("Design MDMT (°C)", value=float(default_mdmt))
199
+ else:
200
+ P = st.number_input("Design Pressure (psi)", value=float(default_P), format="%.2f")
201
+ R = st.number_input("Internal Radius (in)", value=float(default_R), format="%.3f")
202
+ S = st.number_input("Allowable Stress (psi)", value=float(default_S), format="%.1f")
203
+ corrosion = st.number_input("Corrosion Allowance (in)", value=float(default_corrosion), format="%.3f")
204
+ mdmt = st.number_input("Design MDMT (°F)", value=float(default_mdmt))
205
+
206
+ joint_method = st.radio("Joint Efficiency Selection", ["Preset (UW-12)", "Manual Entry"])
207
+ if joint_method == "Preset (UW-12)":
208
+ # include the requested 0.7 option
209
+ E = st.selectbox("Select E (Joint Efficiency)", [1.0, 0.95, 0.9, 0.85, 0.7, 0.65, 0.6, 0.45], index=0)
210
+ else:
211
+ E = st.number_input("Manual Joint Efficiency (0-1)", value=0.85, min_value=0.1, max_value=1.0, format="%.2f")
212
 
213
+ head_type = st.selectbox("Head Type", ["Ellipsoidal", "Torispherical", "Hemispherical"])
 
 
 
 
 
 
 
214
 
215
+ if use_si:
216
+ d_nozzle = st.number_input("Nozzle Diameter (mm)", value=float(default_d_nozzle), format="%.1f")
217
+ t_shell = st.number_input("Shell Thickness Provided (mm)", value=float(default_t_shell), format="%.2f")
218
+ t_nozzle = st.number_input("Nozzle Thickness Provided (mm)", value=float(default_t_nozzle), format="%.2f")
219
+ thickness = st.number_input("Governing Thickness (mm)", value=float(default_thickness), format="%.2f")
220
+ else:
221
+ d_nozzle = st.number_input("Nozzle Diameter (in)", value=float(default_d_nozzle), format="%.3f")
222
+ t_shell = st.number_input("Shell Thickness Provided (in)", value=float(default_t_shell), format="%.4f")
223
+ t_nozzle = st.number_input("Nozzle Thickness Provided (in)", value=float(default_t_nozzle), format="%.4f")
224
+ thickness = st.number_input("Governing Thickness (in)", value=float(default_thickness), format="%.4f")
225
 
226
+ if st.button("🚀 Run Calculation", use_container_width=True):
227
+ st.session_state.run_done = True
228
+ run_calculation = True
 
 
 
 
 
 
 
 
 
 
 
229
 
230
+ if st.session_state.run_done:
231
+ st.success("✅ Calculations completed! See results in the tabs.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  else:
234
+ uploaded_file = st.file_uploader("Upload CSV File", type=["csv"])
235
+ if uploaded_file:
236
+ df = pd.read_csv(uploaded_file)
237
+ st.dataframe(df.head())
238
+ if st.button("🚀 Run Calculation", use_container_width=True):
239
+ # NOTE: CSV processing / mapping not implemented here — expecting columns that match manual inputs
240
+ st.session_state.run_done = True
241
+ run_calculation = True
242
+
243
+ if st.session_state.run_done:
244
+ st.success("✅ Calculations completed! See results in the tabs.")
245
+
246
+ # ========== TABS ==========
247
+ tabs = st.tabs(["Shell", "Head", "Nozzle", "PWHT", "Impact Test", "Summary", "AI Explanation"])
248
+
249
+ if st.session_state.run_done:
250
+ # --- SHELL TAB ---
251
+ with tabs[0]:
252
  try:
253
+ t_shell_calc = shell_thickness(P, R, S, E, corrosion)
254
+ unit_thickness = "mm" if use_si else "in"
255
+ st.metric(f"Required Shell Thickness ({unit_thickness})", f"{t_shell_calc:.4f}")
256
+ except Exception as exc:
257
+ st.error(f"Error computing shell thickness: {exc}")
258
+
259
+ # --- HEAD TAB ---
260
+ with tabs[1]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  try:
262
+ t_head_calc = head_thickness(P, R, S, E, corrosion, head_type)
263
+ unit_thickness = "mm" if use_si else "in"
264
+ st.metric(f"Required {head_type} Head Thickness ({unit_thickness})", f"{t_head_calc:.4f}")
265
+ except Exception as exc:
266
+ st.error(f"Error computing head thickness: {exc}")
267
+
268
+ # --- NOZZLE TAB ---
269
+ with tabs[2]:
270
+ try:
271
+ safe_nozzle = nozzle_reinforcement(P, d_nozzle, t_shell, t_nozzle, S, E)
272
+ st.write("Nozzle Reinforcement Check:", "✅ Safe" if safe_nozzle else "❌ Not Safe")
273
+ st.write("Note: This is a simplified conservative check. For full UG-37 use geometric projection method.")
274
+ except Exception as exc:
275
+ st.error(f"Error computing nozzle reinforcement: {exc}")
276
+
277
+ # --- PWHT TAB ---
278
+ with tabs[3]:
279
+ try:
280
+ pwht_ans = pwht_required(thickness, material="CS", unit_system="SI" if use_si else "USC")
281
+ st.write("PWHT Required:", "✅ Yes" if pwht_ans else "❌ No")
282
+ if use_si:
283
+ st.caption("Threshold used: 38 mm (approx).")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  else:
285
+ st.caption(f"Threshold used: {mpato_psi(0):.4f} (converted threshold from 38 mm).")
286
+ except Exception as exc:
287
+ st.error(f"Error computing PWHT requirement: {exc}")
288
 
289
+ # --- IMPACT TEST TAB ---
290
+ with tabs[4]:
291
+ try:
292
+ impact_ans = impact_test_required(thickness, MDMT=mdmt, material="CS", unit_system="SI" if use_si else "USC")
293
+ st.write("Impact Test Required:", "✅ Yes" if impact_ans else "❌ No")
294
+ st.caption("This is a placeholder approximation. Consult UCS-66 for precise MDMT rules.")
295
+ except Exception as exc:
296
+ st.error(f"Error computing impact test requirement: {exc}")
297
+
298
+ # --- SUMMARY TAB ---
299
+ with tabs[5]:
300
+ try:
301
+ summary_data = {
302
+ "Shell Thickness": t_shell_calc,
303
+ "Head Thickness": t_head_calc,
304
+ "Nozzle Safe": safe_nozzle,
305
+ "PWHT Required": pwht_required(thickness, material="CS", unit_system="SI" if use_si else "USC"),
306
+ "Impact Test Required": impact_test_required(thickness, MDMT=mdmt, material="CS", unit_system="SI" if use_si else "USC"),
307
+ "Unit System": "SI (MPa/mm)" if use_si else "USC (psi/in)",
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  }
309
+ df_summary = pd.DataFrame([summary_data])
310
+ st.dataframe(df_summary)
311
+
312
+ # CSV export
313
+ csv = df_summary.to_csv(index=False).encode("utf-8")
314
+ st.download_button("📥 Download Results (CSV)", csv, "results.csv")
315
+
316
+ # PDF export
317
+ pdf = PDF()
318
+ pdf.chapter_title("Calculation Summary")
319
+ pdf.chapter_body(str(summary_data))
320
+ pdf_file = "results.pdf"
321
+ pdf.output(pdf_file)
322
+ with open(pdf_file, "rb") as f:
323
+ st.download_button("📄 Download PDF Report", f, "results.pdf")
324
+ except Exception as exc:
325
+ st.error(f"Error generating summary: {exc}")
326
+
327
+ # --- AI EXPLANATION TAB ---
328
+ with tabs[6]:
329
+ st.markdown("### 🤖 Ask AI for Explanation")
330
+ if groq_client:
331
+ if st.button("✨ Ask AI", use_container_width=True):
332
+ st.session_state.ai_done = True
333
+ with st.spinner("AI is preparing explanation..."):
334
+ prompt = f"Explain these ASME vessel design results in simple terms: {summary_data}"
335
+ try:
336
+ chat_completion = groq_client.chat.completions.create(
337
+ messages=[{"role": "user", "content": prompt}],
338
+ model="llama-3.1-8b-instant",
339
+ )
340
+ explanation = chat_completion.choices[0].message.content
341
+ st.success("✅ AI Explanation Generated Below")
342
+ st.write(explanation)
343
+ except Exception as exc:
344
+ st.error(f"AI request failed: {exc}")
345
+ if st.session_state.ai_done:
346
+ st.info("✨ AI explanation already generated. Rerun to refresh.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  else:
348
+ st.info("ℹ️ Add your GROQ_API_KEY in Hugging Face secrets to enable AI explanations.")
349
+ else:
350
+ # Placeholders if not run
351
+ for i, msg in enumerate([
352
+ "Shell results", "Head results", "Nozzle results",
353
+ "PWHT decision", "Impact Test decision",
354
+ "Summary", "AI explanation"
355
+ ]):
356
+ with tabs[i]:
357
+ st.info(f"Run calculation to see {msg}.")