""" 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