|
|
""" |
|
|
Tubesheet Thickness Calculator — ASME Division 2 (inspired) (Preliminary) |
|
|
Single-file Streamlit app. |
|
|
|
|
|
Notes: |
|
|
- This implements a Division-2 *inspired* approach with perforation correction options. |
|
|
- This is NOT a verbatim implementation of ASME BPVC text (the code is proprietary). |
|
|
- Use for preliminary sizing only. Verify against ASME Section VIII Division 2 Part 4 / Appendix 4 and |
|
|
have final values reviewed/stamped by a qualified engineer. |
|
|
Author: ChatGPT (assistant) |
|
|
Updated: 2025-09-07 (final preliminary version) |
|
|
""" |
|
|
|
|
|
import streamlit as st |
|
|
import pandas as pd |
|
|
import numpy as np |
|
|
import io |
|
|
import math |
|
|
import plotly.graph_objects as go |
|
|
from datetime import datetime |
|
|
|
|
|
st.set_page_config(page_title="Tubesheet Thickness Calculator (Division 2 inspired)", layout="wide") |
|
|
st.title("Tubesheet Thickness Calculator — ASME Division 2 (inspired) (Preliminary)") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MATERIAL_LOOKUP = { |
|
|
"SA-516 Gr 70": [ |
|
|
{"temperature_C": 20, "allowable_MPa": 138}, |
|
|
{"temperature_C": 100, "allowable_MPa": 125}, |
|
|
{"temperature_C": 200, "allowable_MPa": 110}, |
|
|
], |
|
|
"SA-240 Type 304": [ |
|
|
{"temperature_C": 20, "allowable_MPa": 170}, |
|
|
{"temperature_C": 200, "allowable_MPa": 150}, |
|
|
{"temperature_C": 350, "allowable_MPa": 130}, |
|
|
], |
|
|
"SA-240 Type 316": [ |
|
|
{"temperature_C": 20, "allowable_MPa": 170}, |
|
|
{"temperature_C": 200, "allowable_MPa": 150}, |
|
|
{"temperature_C": 350, "allowable_MPa": 130}, |
|
|
], |
|
|
"SA-105": [ |
|
|
{"temperature_C": 20, "allowable_MPa": 138}, |
|
|
{"temperature_C": 100, "allowable_MPa": 125}, |
|
|
{"temperature_C": 200, "allowable_MPa": 110}, |
|
|
], |
|
|
"SA-36": [ |
|
|
{"temperature_C": 20, "allowable_MPa": 120}, |
|
|
{"temperature_C": 100, "allowable_MPa": 105}, |
|
|
{"temperature_C": 200, "allowable_MPa": 95}, |
|
|
], |
|
|
"SA-387 Grade 11": [ |
|
|
{"temperature_C": 20, "allowable_MPa": 160}, |
|
|
{"temperature_C": 200, "allowable_MPa": 140}, |
|
|
{"temperature_C": 350, "allowable_MPa": 120}, |
|
|
], |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
def mpato_psi(mpa): return mpa * 145.0377377 |
|
|
def psito_mpa(psi): return psi / 145.0377377 |
|
|
def bartompa(bar): return bar * 0.1 |
|
|
def mpatobar(mpa): return mpa / 0.1 |
|
|
def inchtomm(val_in): return val_in * 25.4 |
|
|
def mmtoinch(val_mm): return val_mm / 25.4 |
|
|
|
|
|
def round_up_standard_mm(val_mm): return math.ceil(val_mm) |
|
|
def round_up_standard_in(val_in): |
|
|
sixteenth = 1.0 / 16.0 |
|
|
return math.ceil(val_in / sixteenth) * sixteenth |
|
|
|
|
|
def interpolate_allowable(material, temp_C): |
|
|
if material not in MATERIAL_LOOKUP: |
|
|
return None |
|
|
table = sorted(MATERIAL_LOOKUP[material], key=lambda x: x["temperature_C"]) |
|
|
temps = [r["temperature_C"] for r in table] |
|
|
svals = [r["allowable_MPa"] for r in table] |
|
|
if temp_C <= temps[0]: |
|
|
return svals[0] |
|
|
if temp_C >= temps[-1]: |
|
|
return svals[-1] |
|
|
for i in range(len(temps)-1): |
|
|
if temps[i] <= temp_C <= temps[i+1]: |
|
|
t0, t1, s0, s1 = temps[i], temps[i+1], svals[i], svals[i+1] |
|
|
return s0 + (s1 - s0) * (temp_C - t0) / (t1 - t0) |
|
|
return None |
|
|
|
|
|
|
|
|
|
|
|
BWG_TO_INCH = { |
|
|
"BWG 7": 0.180, "BWG 8": 0.165, "BWG 9": 0.148, "BWG 10": 0.134, |
|
|
"BWG 11": 0.120, "BWG 12": 0.109, "BWG 13": 0.095, "BWG 14": 0.083, |
|
|
"BWG 15": 0.072, "BWG 16": 0.065, "BWG 17": 0.058, "BWG 18": 0.049, |
|
|
"BWG 19": 0.042, "BWG 20": 0.035, "BWG 21": 0.032, "BWG 22": 0.028, |
|
|
"BWG 23": 0.025, "BWG 24": 0.023, "BWG 25": 0.020, "BWG 26": 0.018, |
|
|
"BWG 27": 0.016, "BWG 28": 0.014, |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
with st.sidebar: |
|
|
st.header("Inputs") |
|
|
project_name = st.text_input("Project name", value="Tubesheet Calculation") |
|
|
designer_name = st.text_input("Designer", value="") |
|
|
units = st.selectbox("Units system", ["SI (mm, bar, °C)", "Imperial (in, psi, °F)"]) |
|
|
use_SI = units.startswith("SI") |
|
|
|
|
|
u_len = "mm" if use_SI else "in" |
|
|
u_press = "bar" if use_SI else "psi" |
|
|
u_temp = "°C" if use_SI else "°F" |
|
|
|
|
|
st.subheader("Geometry") |
|
|
OD_ts = st.number_input(f"Tubesheet outer diameter ({u_len})", |
|
|
min_value=10.0 if use_SI else 0.5, |
|
|
value=1000.0 if use_SI else 40.0, |
|
|
step=1.0 if use_SI else 0.25) |
|
|
|
|
|
effective_radius_override = st.checkbox("Override effective calculation radius (optional)") |
|
|
if effective_radius_override: |
|
|
eff_radius = st.number_input( |
|
|
f"Effective radius ({u_len})", |
|
|
min_value=1.0 if use_SI else 0.01, |
|
|
value=(OD_ts/2.0), |
|
|
step=1.0 if not use_SI else 0.01, |
|
|
format="%.3f" |
|
|
) |
|
|
else: |
|
|
eff_radius = None |
|
|
|
|
|
st.subheader("Tube field") |
|
|
N_tubes = st.number_input("Number of tubes (N) - requested (app will place up to this many)", min_value=1, value=100, step=1) |
|
|
|
|
|
|
|
|
if use_SI: |
|
|
tube_od_min = 0.5 |
|
|
tube_od_default = 25.4 |
|
|
tube_od_step = 0.1 |
|
|
tube_od_format = "%.3f" |
|
|
else: |
|
|
tube_od_min = 1.0 / 64.0 |
|
|
tube_od_default = 1.0 |
|
|
tube_od_step = 1.0 / 64.0 |
|
|
tube_od_format = "%.6f" |
|
|
|
|
|
OD_tube = st.number_input(f"Tube outside diameter ({u_len})", |
|
|
min_value=tube_od_min, |
|
|
value=tube_od_default, |
|
|
step=tube_od_step, |
|
|
format=tube_od_format) |
|
|
|
|
|
st.markdown("**Tube wall thickness input**") |
|
|
thickness_input_mode = st.selectbox("Choose thickness input mode", options=["Select BWG gauge", "Enter thickness manually"]) |
|
|
selected_bwg = None |
|
|
t_tube = None |
|
|
if thickness_input_mode == "Select BWG gauge": |
|
|
bwg_options = list(BWG_TO_INCH.keys()) |
|
|
selected_bwg = st.selectbox("BWG gauge", options=bwg_options, index=12) |
|
|
thickness_inch = BWG_TO_INCH[selected_bwg] |
|
|
if use_SI: |
|
|
t_tube = inchtomm(thickness_inch) |
|
|
st.write(f"Selected {selected_bwg} → wall thickness = {t_tube:.3f} mm (from {thickness_inch:.4f} in)") |
|
|
else: |
|
|
t_tube = thickness_inch |
|
|
st.write(f"Selected {selected_bwg} → wall thickness = {t_tube:.4f} in") |
|
|
else: |
|
|
if use_SI: |
|
|
t_tube = st.number_input(f"Tube wall thickness ({u_len})", min_value=0.1, value=1.0, step=0.01, format="%.3f") |
|
|
else: |
|
|
t_tube = st.number_input(f"Tube wall thickness ({u_len})", min_value=1.0/128.0, value=0.035, step=1.0/128.0, format="%.6f") |
|
|
|
|
|
|
|
|
layout = st.selectbox("Tube pitch layout", options=[ |
|
|
"Triangular (hex-packed)", |
|
|
"Square (inline)", |
|
|
"Rotated Square (45°)", |
|
|
"Concentric rings", |
|
|
"Hexagonal grid (same as triangular)" |
|
|
]) |
|
|
|
|
|
default_pitch = (OD_tube * 1.25) if (OD_tube is not None) else (25.4 * 1.25 if use_SI else 1.0 * 1.25) |
|
|
pitch = st.number_input(f"Tube pitch ({u_len})", min_value=max(OD_tube * 1.0, tube_od_min), value=default_pitch, step=tube_od_step, format=tube_od_format) |
|
|
|
|
|
st.subheader("Pressure & Temperature") |
|
|
if use_SI: |
|
|
P_shell = st.number_input(f"Design shell-side pressure ({u_press})", value=3.0, step=0.1) |
|
|
P_tube = st.number_input(f"Design tube-side pressure ({u_press})", value=1.0, step=0.1) |
|
|
T_shell = st.number_input(f"Design shell-side temperature ({u_temp})", value=100.0, step=1.0) |
|
|
T_tube = st.number_input(f"Design tube-side temperature ({u_temp})", value=100.0, step=1.0) |
|
|
else: |
|
|
P_shell = st.number_input(f"Design shell-side pressure ({u_press})", value=50.0, step=1.0) |
|
|
P_tube = st.number_input(f"Design tube-side pressure ({u_press})", value=14.7, step=1.0) |
|
|
T_shell = st.number_input(f"Design shell-side temperature ({u_temp})", value=212.0, step=1.0) |
|
|
T_tube = st.number_input(f"Design tube-side temperature ({u_temp})", value=212.0, step=1.0) |
|
|
|
|
|
st.subheader("Material & Allowances") |
|
|
material_list = list(MATERIAL_LOOKUP.keys()) + ["Other / Manual"] |
|
|
material = st.selectbox("Tubesheet material (ASME II examples)", options=material_list) |
|
|
|
|
|
|
|
|
if use_SI: |
|
|
T_shell_C = float(T_shell) |
|
|
T_tube_C = float(T_tube) |
|
|
else: |
|
|
T_shell_C = (float(T_shell) - 32.0) * (5.0/9.0) |
|
|
T_tube_C = (float(T_tube) - 32.0) * (5.0/9.0) |
|
|
use_temp_for_lookup = max(T_shell_C, T_tube_C) |
|
|
S_allowable_auto = None |
|
|
if material != "Other / Manual": |
|
|
S_allowable_auto = interpolate_allowable(material, use_temp_for_lookup) |
|
|
|
|
|
st.write("Allowable stress lookup (ASME Sec II - illustrative table)") |
|
|
if S_allowable_auto is not None: |
|
|
if use_SI: |
|
|
st.write(f"Auto lookup allowable stress: **{S_allowable_auto:.1f} MPa** at {use_temp_for_lookup:.1f} °C (interpolated)") |
|
|
else: |
|
|
st.write(f"Auto lookup allowable stress: **{mpato_psi(S_allowable_auto):.1f} psi** at {use_temp_for_lookup:.1f} °C (interpolated)") |
|
|
else: |
|
|
st.write("No auto-lookup available for selected material & temperature in bundled table.") |
|
|
|
|
|
S_override = st.checkbox("Manual override allowable stress") |
|
|
S_allowable_manual = None |
|
|
S_allowable_manual_psi = None |
|
|
if S_override: |
|
|
if use_SI: |
|
|
default_S = S_allowable_auto if S_allowable_auto is not None else 100.0 |
|
|
S_allowable_manual = st.number_input("S_allowable (MPa)", min_value=1.0, value=float(default_S), step=1.0) |
|
|
else: |
|
|
default_S_psi = mpato_psi(S_allowable_auto) if S_allowable_auto is not None else mpato_psi(100.0) |
|
|
S_allowable_manual_psi = st.number_input("S_allowable (psi)", min_value=1.0, value=float(default_S_psi), step=1.0) |
|
|
|
|
|
st.subheader("Allowances & Options") |
|
|
if use_SI: |
|
|
CA = st.number_input(f"Corrosion allowance ({u_len})", min_value=0.0, value=1.0, step=0.1) |
|
|
machining = st.number_input(f"Machining allowance ({u_len})", min_value=0.0, value=2.0, step=0.1) |
|
|
else: |
|
|
CA = st.number_input(f"Corrosion allowance ({u_len})", min_value=0.0, value=0.04, step=0.01) |
|
|
machining = st.number_input(f"Machining allowance ({u_len})", min_value=0.0, value=0.08, step=0.01) |
|
|
|
|
|
edge_condition = st.selectbox("Edge condition (plate support)", options=["Clamped (conservative)", "Simply supported (less conservative)"]) |
|
|
safety_factor = st.number_input("Conservative multiplier (SF) applied to allowable stress", min_value=0.5, max_value=3.0, value=1.0, step=0.05) |
|
|
|
|
|
st.subheader("Calculation basis") |
|
|
calc_basis = st.selectbox("Calculation method", options=["ASME Division 2 inspired (default)", "TEMA (illustrative)"]) |
|
|
|
|
|
st.subheader("Perforation correction (how hole pattern affects thickness)") |
|
|
perforation_method = st.selectbox("Perforation method (illustrative)", options=[ |
|
|
"Conservative empirical (Kp = 1/(1 - porosity))", |
|
|
"Moderate (Kp = 1/sqrt(1 - porosity))", |
|
|
"None (ignore perforation effect) — not recommended" |
|
|
]) |
|
|
|
|
|
st.subheader("Extra options") |
|
|
passes = st.number_input("Number of tube-side passes (informational)", min_value=1, value=1, step=1) |
|
|
|
|
|
show_detail = st.checkbox("Show detailed calculations (expandable)", value=False) |
|
|
compute = st.button("Compute") |
|
|
|
|
|
|
|
|
|
|
|
def rotate_points(points, angle_rad): |
|
|
c = math.cos(angle_rad); s = math.sin(angle_rad) |
|
|
return [(c*x - s*y, s*x + c*y) for (x, y) in points] |
|
|
|
|
|
def generate_tube_centers(radius_mm, pitch_mm, layout="Triangular (hex-packed)", tube_OD_mm=0.0, max_count=None): |
|
|
centers = [] |
|
|
R = radius_mm |
|
|
if pitch_mm <= 0 or R <= 0: |
|
|
return centers |
|
|
|
|
|
if layout == "Square (inline)": |
|
|
xs = np.arange(-R, R + 1e-8, pitch_mm) |
|
|
ys = np.arange(-R, R + 1e-8, pitch_mm) |
|
|
for x in xs: |
|
|
for y in ys: |
|
|
if x**2 + y**2 <= (R - tube_OD_mm/2.0)**2: |
|
|
centers.append((x, y)) |
|
|
if max_count and len(centers) >= max_count: |
|
|
return centers |
|
|
|
|
|
elif layout == "Rotated Square (45°)": |
|
|
xs = np.arange(-R * math.sqrt(2), R * math.sqrt(2) + 1e-8, pitch_mm) |
|
|
ys = np.arange(-R * math.sqrt(2), R * math.sqrt(2) + 1e-8, pitch_mm) |
|
|
temp = [(x,y) for x in xs for y in ys] |
|
|
temp_rot = rotate_points(temp, math.radians(45)) |
|
|
for (x,y) in temp_rot: |
|
|
if x**2 + y**2 <= (R - tube_OD_mm/2.0)**2: |
|
|
centers.append((x,y)) |
|
|
if max_count and len(centers) >= max_count: |
|
|
return centers |
|
|
|
|
|
elif layout == "Concentric rings": |
|
|
tube_r = tube_OD_mm / 2.0 |
|
|
if 0 <= (R - tube_r): |
|
|
centers.append((0.0, 0.0)) |
|
|
ring = 1 |
|
|
while True: |
|
|
r_ring = ring * pitch_mm |
|
|
if r_ring > (R - tube_r): |
|
|
break |
|
|
n_on_ring = max(1, int(math.floor(2 * math.pi * r_ring / pitch_mm + 0.5))) |
|
|
for i in range(n_on_ring): |
|
|
ang = 2 * math.pi * i / n_on_ring |
|
|
x = r_ring * math.cos(ang); y = r_ring * math.sin(ang) |
|
|
if x**2 + y**2 <= (R - tube_r)**2: |
|
|
centers.append((x, y)) |
|
|
if max_count and len(centers) >= max_count: |
|
|
return centers |
|
|
ring += 1 |
|
|
|
|
|
else: |
|
|
|
|
|
vert = pitch_mm * math.sqrt(3)/2.0 |
|
|
row_idx = 0 |
|
|
y = -R |
|
|
while y <= R: |
|
|
if row_idx % 2 == 0: |
|
|
xs = np.arange(-R, R + 1e-8, pitch_mm) |
|
|
else: |
|
|
xs = np.arange(-R + pitch_mm/2.0, R + 1e-8, pitch_mm) |
|
|
for x in xs: |
|
|
if x**2 + y**2 <= (R - tube_OD_mm/2.0)**2: |
|
|
centers.append((x, y)) |
|
|
if max_count and len(centers) >= max_count: |
|
|
return centers |
|
|
row_idx += 1 |
|
|
y += vert |
|
|
|
|
|
return centers |
|
|
|
|
|
|
|
|
|
|
|
def compute_all(): |
|
|
|
|
|
if use_SI: |
|
|
OD_ts_mm = float(OD_ts) |
|
|
OD_tube_mm = float(OD_tube) |
|
|
pitch_mm = float(pitch) |
|
|
CA_mm = float(CA) |
|
|
machining_mm = float(machining) |
|
|
t_tube_mm_input = float(t_tube) |
|
|
P_shell_MPa = bartompa(float(P_shell)) |
|
|
P_tube_MPa = bartompa(float(P_tube)) |
|
|
else: |
|
|
OD_ts_mm = inchtomm(float(OD_ts)) |
|
|
OD_tube_mm = inchtomm(float(OD_tube)) |
|
|
pitch_mm = inchtomm(float(pitch)) |
|
|
CA_mm = inchtomm(float(CA)) |
|
|
machining_mm = inchtomm(float(machining)) |
|
|
t_tube_mm_input = inchtomm(float(t_tube)) |
|
|
P_shell_MPa = psito_mpa(float(P_shell)) |
|
|
P_tube_MPa = psito_mpa(float(P_tube)) |
|
|
|
|
|
|
|
|
if eff_radius is not None: |
|
|
if use_SI: |
|
|
a_mm = float(eff_radius) |
|
|
else: |
|
|
a_mm = inchtomm(float(eff_radius)) |
|
|
else: |
|
|
a_mm = OD_ts_mm / 2.0 |
|
|
|
|
|
|
|
|
deltaP_MPa = abs(P_shell_MPa - P_tube_MPa) |
|
|
|
|
|
|
|
|
q = deltaP_MPa |
|
|
|
|
|
|
|
|
if S_override: |
|
|
if use_SI: |
|
|
S_allow_MPa = float(S_allowable_manual) |
|
|
else: |
|
|
S_allow_MPa = psito_mpa(float(S_allowable_manual_psi)) |
|
|
source_S = "Manual override" |
|
|
else: |
|
|
S_allow_MPa = S_allowable_auto if S_allowable_auto is not None else None |
|
|
source_S = "Auto lookup (interpolated)" if S_allowable_auto is not None else "None available" |
|
|
|
|
|
if S_allow_MPa is None: |
|
|
S_allow_MPa = 100.0 |
|
|
source_S = "Default used (no lookup) - user must verify" |
|
|
|
|
|
|
|
|
if calc_basis.startswith("ASME"): |
|
|
k = 0.308 if edge_condition.startswith("Clamped") else 0.375 |
|
|
else: |
|
|
|
|
|
k = 0.27 if edge_condition.startswith("Clamped") else 0.33 |
|
|
|
|
|
SF = float(safety_factor) |
|
|
|
|
|
|
|
|
|
|
|
a = a_mm |
|
|
denom = k * S_allow_MPa * SF |
|
|
if denom <= 0: |
|
|
t_req_base_mm = 0.0 |
|
|
else: |
|
|
t_req_base_mm = math.sqrt(max((q * (a ** 2)) / denom, 0.0)) |
|
|
|
|
|
|
|
|
centers = generate_tube_centers(radius_mm=a_mm - 1e-8, pitch_mm=pitch_mm, layout=layout, tube_OD_mm=OD_tube_mm, max_count=N_tubes) |
|
|
actual_N = len(centers) |
|
|
hole_area_mm2 = actual_N * math.pi * (OD_tube_mm / 2.0) ** 2 |
|
|
plate_area_mm2 = math.pi * (a_mm ** 2) |
|
|
porosity = (hole_area_mm2 / plate_area_mm2) if plate_area_mm2 > 0 else 0.0 |
|
|
|
|
|
|
|
|
if perforation_method.startswith("Conservative empirical"): |
|
|
|
|
|
Kp = 1.0 / max(1.0 - porosity, 1e-6) |
|
|
elif perforation_method.startswith("Moderate"): |
|
|
|
|
|
Kp = 1.0 / math.sqrt(max(1.0 - porosity, 1e-6)) |
|
|
else: |
|
|
Kp = 1.0 |
|
|
|
|
|
|
|
|
|
|
|
t_req_perforated_mm = t_req_base_mm * math.sqrt(Kp) |
|
|
|
|
|
|
|
|
t_final_unrounded_mm = t_req_perforated_mm + CA_mm + machining_mm |
|
|
|
|
|
t_final_rounded_mm = round_up_standard_mm(t_final_unrounded_mm) |
|
|
|
|
|
|
|
|
if use_SI: |
|
|
results = { |
|
|
"t_req_base_mm": t_req_base_mm, |
|
|
"t_req_perforated_mm": t_req_perforated_mm, |
|
|
"t_final_unrounded_mm": t_final_unrounded_mm, |
|
|
"t_final_rounded_mm": t_final_rounded_mm, |
|
|
"S_allow_MPa": S_allow_MPa, |
|
|
"S_allow_display": f"{S_allow_MPa:.2f} MPa", |
|
|
"deltaP_MPa": deltaP_MPa, |
|
|
"deltaP_display": f"{deltaP_MPa:.4f} MPa", |
|
|
"hole_removed_pct": porosity * 100.0, |
|
|
"Kp": Kp, |
|
|
"actual_N": actual_N, |
|
|
"a_mm": a_mm, |
|
|
"t_tube_mm_input": t_tube_mm_input, |
|
|
"source_S": source_S |
|
|
} |
|
|
else: |
|
|
results = { |
|
|
"t_req_base_in": mmtoinch(t_req_base_mm), |
|
|
"t_req_perforated_in": mmtoinch(t_req_perforated_mm), |
|
|
"t_final_unrounded_in": mmtoinch(t_final_unrounded_mm), |
|
|
"t_final_rounded_in": round_up_standard_in(mmtoinch(t_final_rounded_mm)), |
|
|
"S_allow_MPa": S_allow_MPa, |
|
|
"S_allow_display": f"{mpato_psi(S_allow_MPa):.1f} psi", |
|
|
"deltaP_MPa": deltaP_MPa, |
|
|
"deltaP_display": f"{mpato_psi(deltaP_MPa):.2f} psi", |
|
|
"hole_removed_pct": porosity * 100.0, |
|
|
"Kp": Kp, |
|
|
"actual_N": actual_N, |
|
|
"a_in": mmtoinch(a_mm), |
|
|
"t_tube_in_input": mmtoinch(t_tube_mm_input), |
|
|
"source_S": source_S |
|
|
} |
|
|
|
|
|
|
|
|
log_lines = [] |
|
|
log_lines.append(f"Project: {project_name}") |
|
|
log_lines.append(f"Designer: {designer_name} Date: {datetime.now().strftime('%Y-%m-%d')}") |
|
|
log_lines.append("(Internal units: lengths=mm, pressure=MPa, stress=MPa)") |
|
|
log_lines.append(f"Selected units: {units}") |
|
|
log_lines.append(f"Tubesheet OD = {OD_ts} {u_len} -> effective radius a = {a_mm:.3f} mm") |
|
|
log_lines.append(f"Requested N_tubes = {N_tubes}, Placed (based on pitch/layout) = {actual_N}") |
|
|
log_lines.append(f"Tube OD = {OD_tube} {u_len} -> OD_tube_mm = {OD_tube_mm:.4f} mm") |
|
|
log_lines.append(f"ΔP = {deltaP_MPa:.6f} MPa") |
|
|
log_lines.append(f"Material: {material} (S source: {source_S}), S_used = {S_allow_MPa:.3f} MPa") |
|
|
log_lines.append(f"Edge condition k = {k}, safety multiplier SF = {SF}, calc basis = {calc_basis}") |
|
|
log_lines.append("Base plate-bending formula: t_req_base = sqrt( (q * a^2) / (k * S * SF) )") |
|
|
log_lines.append(f"Base t_req = {t_req_base_mm:.4f} mm") |
|
|
log_lines.append(f"Porosity (hole area / plate area) = {porosity:.5f} -> {porosity*100.0:.3f} %") |
|
|
log_lines.append(f"Perforation method = {perforation_method} -> Kp = {Kp:.4f}") |
|
|
log_lines.append(f"Perforation-corrected t_req = {t_req_perforated_mm:.4f} mm") |
|
|
log_lines.append(f"Corrosion allowance = {CA_mm:.3f} mm, machining = {machining_mm:.3f} mm") |
|
|
log_lines.append(f"t_final (unrounded) = {t_final_unrounded_mm:.4f} mm") |
|
|
log_lines.append(f"t_final (rounded) = {t_final_rounded_mm:.3f} mm") |
|
|
if porosity > 0.5: |
|
|
log_lines.append("WARNING: Very high hole area fraction (>50%). Perforation corrections are highly conservative; check layout and consider reinforcement or alternative design.") |
|
|
if deltaP_MPa == 0: |
|
|
log_lines.append("NOTE: ΔP = 0 (no differential pressure). Required thickness from pressure loading is zero; consider mechanical/assembly loads & code minimum thickness requirements.") |
|
|
log_text = "\n".join(log_lines) |
|
|
|
|
|
|
|
|
summary = [] |
|
|
summary.append(["Tubesheet OD", f"{OD_ts} {u_len}"]) |
|
|
summary.append(["Number of tubes (requested)", f"{N_tubes}"]) |
|
|
summary.append(["Number of tubes (placed)", f"{actual_N}"]) |
|
|
summary.append(["Tube OD", f"{OD_tube} {u_len}"]) |
|
|
if use_SI: |
|
|
summary.append(["Tube wall thickness (input)", f"{t_tube_mm_input:.3f} mm"]) |
|
|
else: |
|
|
summary.append(["Tube wall thickness (input)", f"{mmtoinch(t_tube_mm_input):.4f} in"]) |
|
|
summary.append(["Pitch", f"{pitch} {u_len}"]) |
|
|
summary.append(["Layout", f"{layout}"]) |
|
|
summary.append(["Material", material]) |
|
|
summary.append(["S_used", results['S_allow_display']]) |
|
|
summary.append(["ΔP", results['deltaP_display']]) |
|
|
if use_SI: |
|
|
summary.append(["t_req_base (mm)", f"{results['t_req_base_mm']:.3f}"]) |
|
|
summary.append(["t_req_perforated (mm)", f"{results['t_req_perforated_mm']:.3f}"]) |
|
|
summary.append(["t_final_unrounded (mm)", f"{results['t_final_unrounded_mm']:.3f}"]) |
|
|
summary.append(["t_final_rounded (mm)", f"{results['t_final_rounded_mm']:.3f}"]) |
|
|
else: |
|
|
summary.append(["t_req_base (in)", f"{results['t_req_base_in']:.4f}"]) |
|
|
summary.append(["t_req_perforated (in)", f"{results['t_req_perforated_in']:.4f}"]) |
|
|
summary.append(["t_final_unrounded (in)", f"{results['t_final_unrounded_in']:.4f}"]) |
|
|
summary.append(["t_final_rounded (in)", f"{results['t_final_rounded_in']:.4f}"]) |
|
|
summary.append(["Hole removed (%)", f"{porosity*100.0:.3f}%"]) |
|
|
summary.append(["Perforation factor Kp", f"{Kp:.4f}"]) |
|
|
summary.append(["Tube-side passes", f"{passes}"]) |
|
|
summary_df = pd.DataFrame(summary, columns=["Parameter", "Value"]) |
|
|
|
|
|
csv_buffer = io.StringIO() |
|
|
summary_df.to_csv(csv_buffer, index=False) |
|
|
csv_data = csv_buffer.getvalue().encode("utf-8") |
|
|
|
|
|
|
|
|
plot_centers = [(x / a_mm, y / a_mm) for (x, y) in centers] |
|
|
|
|
|
return results, log_text, summary_df, csv_data, plot_centers, OD_tube_mm, a_mm |
|
|
|
|
|
|
|
|
|
|
|
if compute: |
|
|
results, log_text, summary_df, csv_bytes, plot_centers, OD_tube_mm, a_mm = compute_all() |
|
|
|
|
|
st.subheader("Results (preliminary)") |
|
|
if use_SI: |
|
|
c1, c2, c3, c4 = st.columns(4) |
|
|
c1.metric("Base required thickness (t_req_base)", f"{results['t_req_base_mm']:.3f} mm") |
|
|
c2.metric("Perforation-corrected t_req", f"{results['t_req_perforated_mm']:.3f} mm") |
|
|
c3.metric("Final thickness (rounded)", f"{results['t_final_rounded_mm']:.3f} mm") |
|
|
c4.metric("ΔP (governing)", f"{results['deltaP_MPa']:.4f} MPa") |
|
|
else: |
|
|
c1, c2, c3, c4 = st.columns(4) |
|
|
c1.metric("Base required thickness (t_req_base)", f"{results['t_req_base_in']:.4f} in") |
|
|
c2.metric("Perforation-corrected t_req", f"{results['t_req_perforated_in']:.4f} in") |
|
|
c3.metric("Final thickness (rounded)", f"{results['t_final_rounded_in']:.4f} in") |
|
|
c4.metric("ΔP (governing)", results['deltaP_display']) |
|
|
|
|
|
left, right = st.columns([2,3]) |
|
|
with left: |
|
|
st.markdown("**Summary table**") |
|
|
st.dataframe(summary_df, use_container_width=True) |
|
|
st.download_button("Download summary CSV", data=csv_bytes, file_name="tubesheet_summary.csv", mime="text/csv") |
|
|
|
|
|
with right: |
|
|
st.markdown("**Schematic (normalized to tubesheet radius)**") |
|
|
fig = go.Figure() |
|
|
theta = np.linspace(0, 2*np.pi, 400) |
|
|
fig.add_trace(go.Scatter(x=np.cos(theta), y=np.sin(theta), mode="lines", line=dict(width=2, color="RoyalBlue"), showlegend=False)) |
|
|
|
|
|
xs = [c[0] for c in plot_centers]; ys = [c[1] for c in plot_centers] |
|
|
if a_mm > 0: |
|
|
|
|
|
frac = (OD_tube_mm / 2.0) / a_mm |
|
|
marker_size = max(4, min(40, frac * 800)) |
|
|
else: |
|
|
marker_size = 6 |
|
|
fig.add_trace(go.Scatter(x=xs, y=ys, mode="markers", marker=dict(size=marker_size), showlegend=False)) |
|
|
fig.update_layout(width=800, height=800, margin=dict(l=10,r=10,t=30,b=10), |
|
|
xaxis=dict(visible=False, range=[-1.05,1.05]), yaxis=dict(visible=False, range=[-1.05,1.05]), |
|
|
title_text="Tubesheet layout (normalized to radius = 1)") |
|
|
st.plotly_chart(fig, use_container_width=True) |
|
|
|
|
|
try: |
|
|
img_bytes = fig.to_image(format="png", width=1000, height=1000, scale=1) |
|
|
st.download_button("Download schematic PNG", data=img_bytes, file_name="tubesheet_layout.png", mime="image/png") |
|
|
except Exception: |
|
|
st.info("PNG export requires 'kaleido'. See requirements.txt if you want PNG export.") |
|
|
|
|
|
if show_detail: |
|
|
st.subheader("Detailed calculation log") |
|
|
st.code(log_text, language="text") |
|
|
|
|
|
st.markdown("### Notes & Warnings") |
|
|
st.markdown("- This is a **preliminary** design tool. The perforation corrections are illustrative approximations — verify with ASME BPVC Section VIII Division 2 Part 4 and mandatory appendices.") |
|
|
st.markdown("- The bundled material table is illustrative. **Use ASME Section II values for the exact material & temperature.**") |
|
|
st.markdown("- Final design must be verified and stamped by a qualified engineer.") |
|
|
|
|
|
else: |
|
|
st.info("Fill inputs on the left and click **Compute** to estimate tubesheet thickness (preliminary).") |
|
|
|
|
|
|
|
|
|
|
|
st.sidebar.markdown("---") |
|
|
st.sidebar.markdown("### Presets") |
|
|
if st.sidebar.button("Example 1 (SI)"): |
|
|
st.info("Preset example: tubesheet OD 1000 mm, tube OD 25.4 mm, P_shell=3 bar, P_tube=1 bar. (To set widgets programmatically use session_state — see explanation below.)") |
|
|
if st.sidebar.button("Example 2 (Imperial)"): |
|
|
st.info("Preset example: tubesheet OD 40 in, tube OD 0.75 in, P_shell=50 psi, P_tube=14.7 psi.") |
|
|
|
|
|
|
|
|
|