Umar4321's picture
Update app.py
a4272b3 verified
"""
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)")
# -----------------------
# Example material allowable stresses (illustrative only)
# Replace with verified ASME Section II values for production use.
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},
],
}
# -----------------------
# Helpers / conversions
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 table (in inches)
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,
}
# -----------------------
# Sidebar inputs
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)
# OD and pitch defaults
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")
# layouts (kept expanded)
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)
# temperature for lookup (°C)
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")
# -----------------------
# Grid / layout helpers
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:
# Triangular / Hexagonal close pack
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
# -----------------------
# Calculation logic
def compute_all():
# convert everything into internal consistent units
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))
# effective radius
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
# differential pressure (governing)
deltaP_MPa = abs(P_shell_MPa - P_tube_MPa)
# early sanity: if no differential pressure, t_req should be zero (but user probably wants at least a minimum)
# we still compute but we warn the user
q = deltaP_MPa # MPa == N/mm^2
# allowable stress
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"
# choose k (edge) depending on basis
if calc_basis.startswith("ASME"):
k = 0.308 if edge_condition.startswith("Clamped") else 0.375
else:
# illustrative TEMA modifiers (not from code)
k = 0.27 if edge_condition.startswith("Clamped") else 0.33
SF = float(safety_factor)
# base plate bending required thickness (dimensional check preserved)
# ensure denom positive
a = a_mm
denom = k * S_allow_MPa * SF # (dimension N/mm^2)
if denom <= 0:
t_req_base_mm = 0.0
else:
t_req_base_mm = math.sqrt(max((q * (a ** 2)) / denom, 0.0))
# determine tube centers using pitch/layout (limit to N_tubes)
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 # fraction
# Perforation correction Kp (illustrative options)
if perforation_method.startswith("Conservative empirical"):
# conservative: Kp = 1 / (1 - porosity) (blows up as porosity -> 1)
Kp = 1.0 / max(1.0 - porosity, 1e-6)
elif perforation_method.startswith("Moderate"):
# moderate: Kp = 1 / sqrt(1 - porosity)
Kp = 1.0 / math.sqrt(max(1.0 - porosity, 1e-6))
else:
Kp = 1.0
# As Division 2 Part 4 style reasoning: increase thickness proportionally to sqrt(Kp)
# (this is an engineering conservative approximation — verify with ASME mandatory appendices).
t_req_perforated_mm = t_req_base_mm * math.sqrt(Kp)
# final thickness with allowances
t_final_unrounded_mm = t_req_perforated_mm + CA_mm + machining_mm
# rounding rule: round up to nearest 1 mm (SI) or 1/16 in (converted)
t_final_rounded_mm = round_up_standard_mm(t_final_unrounded_mm)
# assemble results (units displayed as selected)
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
}
# build log
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 dataframe
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")
# prepare plotting coordinates normalized to radius=1
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
# -----------------------
# Run compute & UI output
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))
# plot tubes scaled by OD
xs = [c[0] for c in plot_centers]; ys = [c[1] for c in plot_centers]
if a_mm > 0:
# marker size as function of tube OD / a
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 PNG export (kaleido recommended)
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).")
# -----------------------
# Presets (non-programmatic; use session_state if you want to set widgets programmatically)
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.")
# End of app