Update app.py
Browse files
app.py
CHANGED
|
@@ -1,12 +1,20 @@
|
|
| 1 |
"""
|
| 2 |
-
Tubesheet Thickness Calculator
|
| 3 |
Single-file Streamlit app.
|
| 4 |
|
| 5 |
-
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
import streamlit as st
|
|
@@ -22,7 +30,7 @@ st.title("Tubesheet Thickness Calculator — ASME VIII-1 (Preliminary)")
|
|
| 22 |
|
| 23 |
# -----------------------
|
| 24 |
# Bundled (example) material table (sample values)
|
| 25 |
-
# NOTE:
|
| 26 |
MATERIAL_LOOKUP = {
|
| 27 |
"SA-516 Gr 70": [
|
| 28 |
{"temperature_C": 20, "allowable_MPa": 138},
|
|
@@ -113,8 +121,6 @@ def interpolate_allowable(material, temp_C):
|
|
| 113 |
|
| 114 |
# -----------------------
|
| 115 |
# BWG table (typical reference values in inches)
|
| 116 |
-
# NOTE: These are typical/approximate gauge-to-thickness values used for reference.
|
| 117 |
-
# Validate against manufacturer data for critical designs.
|
| 118 |
BWG_TO_INCH = {
|
| 119 |
"BWG 7": 0.180,
|
| 120 |
"BWG 8": 0.165,
|
|
@@ -142,7 +148,6 @@ BWG_TO_INCH = {
|
|
| 142 |
|
| 143 |
# -----------------------
|
| 144 |
# Sidebar — Inputs
|
| 145 |
-
# -----------------------
|
| 146 |
with st.sidebar:
|
| 147 |
st.header("Inputs")
|
| 148 |
project_name = st.text_input("Project name", value="Tubesheet Calculation")
|
|
@@ -166,18 +171,20 @@ with st.sidebar:
|
|
| 166 |
|
| 167 |
effective_radius_override = st.checkbox("Override effective calculation radius (optional)")
|
| 168 |
if effective_radius_override:
|
| 169 |
-
eff_radius = st.number_input(
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
| 174 |
else:
|
| 175 |
eff_radius = None
|
| 176 |
|
| 177 |
st.subheader("Tube field")
|
| 178 |
N_tubes = st.number_input("Number of tubes (N)", min_value=1, value=100, step=1)
|
| 179 |
|
| 180 |
-
# Allow small tube ODs: set min depending on units.
|
| 181 |
if use_SI:
|
| 182 |
tube_od_min = 0.5 # mm
|
| 183 |
tube_od_default = 25.4 # mm
|
|
@@ -201,19 +208,16 @@ with st.sidebar:
|
|
| 201 |
selected_bwg = None
|
| 202 |
t_tube = None
|
| 203 |
if thickness_input_mode == "Select BWG gauge":
|
| 204 |
-
# show BWG options; display thickness in chosen units
|
| 205 |
bwg_options = list(BWG_TO_INCH.keys())
|
| 206 |
-
selected_bwg = st.selectbox("BWG gauge", options=bwg_options, index=12)
|
| 207 |
-
# convert to selected units:
|
| 208 |
thickness_inch = BWG_TO_INCH[selected_bwg]
|
| 209 |
if use_SI:
|
| 210 |
t_tube = inchtomm(thickness_inch)
|
| 211 |
-
st.write(f"Selected {selected_bwg} → wall thickness = {t_tube:.3f} mm (
|
| 212 |
else:
|
| 213 |
t_tube = thickness_inch
|
| 214 |
st.write(f"Selected {selected_bwg} → wall thickness = {t_tube:.4f} in")
|
| 215 |
else:
|
| 216 |
-
# manual entry; allow fine fractions for imperial
|
| 217 |
if use_SI:
|
| 218 |
t_tube_min = 0.1
|
| 219 |
t_tube_default = 1.0
|
|
@@ -233,7 +237,7 @@ with st.sidebar:
|
|
| 233 |
|
| 234 |
layout = st.selectbox("Tube pitch layout", options=["Triangular", "Square"])
|
| 235 |
|
| 236 |
-
#
|
| 237 |
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)
|
| 238 |
pitch = st.number_input(f"Tube pitch ({u_len})",
|
| 239 |
min_value=max(OD_tube * 1.0, tube_od_min),
|
|
@@ -242,7 +246,6 @@ with st.sidebar:
|
|
| 242 |
format=tube_od_format)
|
| 243 |
|
| 244 |
st.subheader("Pressure & Temperature")
|
| 245 |
-
# default pressures
|
| 246 |
if use_SI:
|
| 247 |
P_shell = st.number_input(f"Design shell-side pressure ({u_press})", value=3.0, step=0.1)
|
| 248 |
P_tube = st.number_input(f"Design tube-side pressure ({u_press})", value=1.0, step=0.1)
|
|
@@ -258,7 +261,7 @@ with st.sidebar:
|
|
| 258 |
material_list = ["SA-516 Gr 70", "SA-240 Type 304", "SA-240 Type 316", "SA-105", "SA-36", "SA-387 Grade 11", "Other / Manual"]
|
| 259 |
material = st.selectbox("Tubesheet material (ASME II examples)", options=material_list)
|
| 260 |
|
| 261 |
-
# Determine default usable temperature for lookup
|
| 262 |
if use_SI:
|
| 263 |
T_shell_C = float(T_shell)
|
| 264 |
T_tube_C = float(T_tube)
|
|
@@ -273,16 +276,24 @@ with st.sidebar:
|
|
| 273 |
|
| 274 |
st.write("Allowable stress lookup (ASME Sec II - example table)")
|
| 275 |
if S_allowable_auto is not None:
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
| 277 |
else:
|
| 278 |
st.write("No auto-lookup available for selected material & temperature in bundled table.")
|
| 279 |
|
| 280 |
-
S_override = st.checkbox("Manual override allowable stress
|
|
|
|
|
|
|
|
|
|
| 281 |
if S_override:
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
| 286 |
|
| 287 |
st.subheader("Allowances & Options")
|
| 288 |
if use_SI:
|
|
@@ -294,12 +305,75 @@ with st.sidebar:
|
|
| 294 |
|
| 295 |
edge_condition = st.selectbox("Edge condition (plate support)", options=["Clamped (conservative)", "Simply supported (less conservative)"])
|
| 296 |
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
show_detail = st.checkbox("Show detailed calculations (expandable)", value=False)
|
| 298 |
compute = st.button("Compute")
|
| 299 |
|
| 300 |
# -----------------------
|
| 301 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
# -----------------------
|
|
|
|
| 303 |
def compute_all():
|
| 304 |
# Unit conversions into consistent internal units:
|
| 305 |
# lengths in mm, pressure in MPa, stresses in MPa
|
|
@@ -309,8 +383,7 @@ def compute_all():
|
|
| 309 |
pitch_mm = float(pitch)
|
| 310 |
CA_mm = float(CA)
|
| 311 |
machining_mm = float(machining)
|
| 312 |
-
|
| 313 |
-
t_tube_mm_input = float(t_tube) if use_SI else inchtomm(float(t_tube)) # safe fallback
|
| 314 |
P_shell_MPa = bartompa(float(P_shell))
|
| 315 |
P_tube_MPa = bartompa(float(P_tube))
|
| 316 |
T_shellC = float(T_shell)
|
|
@@ -321,7 +394,6 @@ def compute_all():
|
|
| 321 |
pitch_mm = inchtomm(float(pitch))
|
| 322 |
CA_mm = inchtomm(float(CA))
|
| 323 |
machining_mm = inchtomm(float(machining))
|
| 324 |
-
# t_tube currently in inches if user selected BWG or manual imperial entry
|
| 325 |
t_tube_mm_input = inchtomm(float(t_tube))
|
| 326 |
P_shell_MPa = psito_mpa(float(P_shell))
|
| 327 |
P_tube_MPa = psito_mpa(float(P_tube))
|
|
@@ -342,23 +414,34 @@ def compute_all():
|
|
| 342 |
q = deltaP_MPa # MPa (N/mm^2) as a uniform load approximation
|
| 343 |
|
| 344 |
# allowable stress selection
|
| 345 |
-
if S_override
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
source_S = "Manual override"
|
| 348 |
else:
|
| 349 |
S_allow_MPa = S_allowable_auto if S_allowable_auto is not None else None
|
| 350 |
source_S = "Auto lookup (interpolated)" if S_allowable_auto is not None else "None available"
|
| 351 |
|
| 352 |
-
# if
|
| 353 |
if S_allow_MPa is None:
|
| 354 |
S_allow_MPa = 100.0
|
| 355 |
source_S = "Default used (no lookup) - user must verify"
|
| 356 |
|
| 357 |
-
# choose k based on edge condition
|
| 358 |
-
if
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
| 360 |
else:
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
|
| 363 |
SF = float(safety_factor)
|
| 364 |
|
|
@@ -371,10 +454,13 @@ def compute_all():
|
|
| 371 |
else:
|
| 372 |
t_req_mm = math.sqrt(max(numerator / denom, 0.0))
|
| 373 |
|
|
|
|
|
|
|
|
|
|
| 374 |
# hole area and percent removed
|
| 375 |
-
hole_area_mm2 =
|
| 376 |
plate_area_mm2 = math.pi * (a_mm ** 2)
|
| 377 |
-
hole_removed_pct = (hole_area_mm2 / plate_area_mm2) * 100.0
|
| 378 |
|
| 379 |
# local bearing / ligament check (simple)
|
| 380 |
net_area_mm2 = max(plate_area_mm2 - hole_area_mm2, 1.0)
|
|
@@ -392,12 +478,16 @@ def compute_all():
|
|
| 392 |
"t_final_unrounded_mm": t_final_unrounded_mm,
|
| 393 |
"t_final_rounded_mm": t_final_rounded_mm,
|
| 394 |
"S_allow_MPa": S_allow_MPa,
|
|
|
|
| 395 |
"deltaP_MPa": deltaP_MPa,
|
|
|
|
| 396 |
"hole_removed_pct": hole_removed_pct,
|
| 397 |
"avg_membrane_stress_approx_MPa": avg_membrane_stress_approx,
|
|
|
|
| 398 |
"bearing_flag": bearing_flag,
|
| 399 |
"a_mm": a_mm,
|
| 400 |
"t_tube_mm_input": t_tube_mm_input,
|
|
|
|
| 401 |
"source_S": source_S
|
| 402 |
}
|
| 403 |
else:
|
|
@@ -406,15 +496,16 @@ def compute_all():
|
|
| 406 |
"t_final_unrounded_in": mmtoinch(t_final_unrounded_mm),
|
| 407 |
"t_final_rounded_in": round_up_standard_in(mmtoinch(t_final_rounded_mm)),
|
| 408 |
"S_allow_MPa": S_allow_MPa,
|
| 409 |
-
"
|
| 410 |
"deltaP_MPa": deltaP_MPa,
|
| 411 |
-
"
|
| 412 |
"hole_removed_pct": hole_removed_pct,
|
| 413 |
"avg_membrane_stress_approx_MPa": avg_membrane_stress_approx,
|
| 414 |
-
"
|
| 415 |
"bearing_flag": bearing_flag,
|
| 416 |
"a_in": mmtoinch(a_mm),
|
| 417 |
"t_tube_in_input": mmtoinch(t_tube_mm_input),
|
|
|
|
| 418 |
"source_S": source_S
|
| 419 |
}
|
| 420 |
|
|
@@ -425,7 +516,7 @@ def compute_all():
|
|
| 425 |
log_lines.append("---- Units (internal calculations): lengths=mm, pressures=MPa, stress=MPa ----")
|
| 426 |
log_lines.append(f"Selected units: {units}")
|
| 427 |
log_lines.append(f"OD_ts = {OD_ts} {u_len} -> a = {a_mm:.2f} mm")
|
| 428 |
-
log_lines.append(f"N_tubes = {N_tubes}, OD_tube = {OD_tube} {u_len} -> OD_tube_mm = {OD_tube_mm:.6f} mm")
|
| 429 |
log_lines.append(f"Tube wall thickness input mode = {thickness_input_mode}")
|
| 430 |
if thickness_input_mode == "Select BWG":
|
| 431 |
log_lines.append(f"Selected BWG = {selected_bwg}, t_tube = {t_tube_mm_input:.4f} mm")
|
|
@@ -434,7 +525,7 @@ def compute_all():
|
|
| 434 |
log_lines.append(f"Pressures: P_shell = {P_shell} {u_press} ({P_shell_MPa:.6f} MPa), P_tube = {P_tube} {u_press} ({P_tube_MPa:.6f} MPa)")
|
| 435 |
log_lines.append(f"ΔP (governing) = |P_shell - P_tube| = {deltaP_MPa:.6f} MPa")
|
| 436 |
log_lines.append(f"Material: {material} (S source: {source_S}), S_used = {S_allow_MPa:.3f} MPa")
|
| 437 |
-
log_lines.append(f"Edge condition k = {k}, safety multiplier SF = {SF}")
|
| 438 |
log_lines.append(f"Plate bending formula used: t_req = sqrt( (q * a^2) / (k * S * SF) )")
|
| 439 |
log_lines.append(f"Substitution: q={q:.6f} MPa, a={a_mm:.3f} mm, k={k}, S={S_allow_MPa:.3f} MPa, SF={SF}")
|
| 440 |
log_lines.append(f"Computed t_req = {t_req_mm:.3f} mm")
|
|
@@ -450,25 +541,32 @@ def compute_all():
|
|
| 450 |
# produce summary table (pandas)
|
| 451 |
summary = []
|
| 452 |
summary.append(["Tubesheet OD", f"{OD_ts} {u_len}"])
|
| 453 |
-
summary.append(["Number of tubes", f"{N_tubes}"])
|
|
|
|
| 454 |
summary.append(["Tube OD", f"{OD_tube} {u_len}"])
|
| 455 |
if use_SI:
|
| 456 |
summary.append(["Tube wall thickness (input)", f"{t_tube_mm_input:.3f} mm"])
|
| 457 |
else:
|
| 458 |
summary.append(["Tube wall thickness (input)", f"{mmtoinch(t_tube_mm_input):.4f} in"])
|
| 459 |
summary.append(["Pitch", f"{pitch} {u_len}"])
|
|
|
|
| 460 |
summary.append(["Material", material])
|
| 461 |
-
summary.append(["S_used
|
| 462 |
-
summary.append(["ΔP",
|
| 463 |
if use_SI:
|
| 464 |
-
summary.append(["t_req (mm)", f"{t_req_mm:.3f}"])
|
| 465 |
-
summary.append(["t_final_unrounded (mm)", f"{t_final_unrounded_mm:.3f}"])
|
| 466 |
-
summary.append(["t_final_rounded (mm)", f"{t_final_rounded_mm:.3f}"])
|
| 467 |
else:
|
| 468 |
-
summary.append(["t_req (in)", f"{
|
| 469 |
-
summary.append(["t_final_unrounded (in)", f"{
|
| 470 |
-
summary.append(["t_final_rounded (in)", f"{
|
| 471 |
summary.append(["Hole removed (%)", f"{hole_removed_pct:.2f}%"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
summary_df = pd.DataFrame(summary, columns=["Parameter", "Value"])
|
| 473 |
|
| 474 |
# CSV export
|
|
@@ -476,13 +574,16 @@ def compute_all():
|
|
| 476 |
summary_df.to_csv(csv_buffer, index=False)
|
| 477 |
csv_data = csv_buffer.getvalue().encode("utf-8")
|
| 478 |
|
| 479 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
# -----------------------
|
| 482 |
# Run compute and display results
|
| 483 |
-
# -----------------------
|
| 484 |
if compute:
|
| 485 |
-
results, log_text, summary_df, csv_bytes = compute_all()
|
| 486 |
|
| 487 |
# Show KPI cards
|
| 488 |
st.subheader("Results")
|
|
@@ -497,34 +598,37 @@ if compute:
|
|
| 497 |
c1.metric("Required thickness (t_req)", f"{results['t_req_in']:.4f} in")
|
| 498 |
c2.metric("Final thickness (unrounded)", f"{results['t_final_unrounded_in']:.4f} in")
|
| 499 |
c3.metric("Final thickness (rounded)", f"{results['t_final_rounded_in']:.4f} in")
|
| 500 |
-
c4.metric("ΔP",
|
| 501 |
|
| 502 |
# Summary table and schematic
|
| 503 |
left, right = st.columns([2,3])
|
| 504 |
with left:
|
| 505 |
st.markdown("**Summary table**")
|
| 506 |
st.dataframe(summary_df, use_container_width=True)
|
| 507 |
-
|
| 508 |
st.download_button("Download summary CSV", data=csv_bytes, file_name="tubesheet_summary.csv", mime="text/csv")
|
| 509 |
|
| 510 |
with right:
|
| 511 |
-
st.markdown("**Schematic (
|
| 512 |
-
# create a simple schematic: circle for tubesheet, points for tube field (visual)
|
| 513 |
fig = go.Figure()
|
|
|
|
| 514 |
theta = np.linspace(0, 2*np.pi, 200)
|
| 515 |
fig.add_trace(go.Scatter(x=np.cos(theta), y=np.sin(theta), mode="lines", name="Tubesheet OD",
|
| 516 |
-
line=dict(color="RoyalBlue")))
|
| 517 |
-
|
| 518 |
-
xs = []
|
| 519 |
-
ys = []
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
st.plotly_chart(fig, use_container_width=True)
|
| 529 |
|
| 530 |
# Calculation log
|
|
@@ -534,9 +638,10 @@ if compute:
|
|
| 534 |
|
| 535 |
# Warnings and notes
|
| 536 |
st.markdown("### Notes & Warnings")
|
| 537 |
-
st.markdown("- This is a **preliminary** sizing tool using a simplified flat-plate approximation (
|
| 538 |
st.markdown("- The bundled material table is illustrative. **Verify allowable stresses with ASME Section II** for your material and temperature.")
|
| 539 |
-
st.markdown("- The BWG → thickness mapping is provided as a
|
|
|
|
| 540 |
st.markdown("- Final design must be verified and stamped by a qualified engineer as per ASME Section VIII Division 1.")
|
| 541 |
|
| 542 |
else:
|
|
@@ -544,12 +649,11 @@ else:
|
|
| 544 |
|
| 545 |
# -----------------------
|
| 546 |
# Presets (quick-load examples)
|
| 547 |
-
# -----------------------
|
| 548 |
st.sidebar.markdown("---")
|
| 549 |
st.sidebar.markdown("### Presets")
|
| 550 |
if st.sidebar.button("Example 1 (SI)"):
|
| 551 |
-
st.
|
| 552 |
if st.sidebar.button("Example 2 (Imperial)"):
|
| 553 |
-
st.
|
| 554 |
|
| 555 |
# End of app
|
|
|
|
| 1 |
"""
|
| 2 |
+
Tubesheet Thickness Calculator — ASME (with optional TEMA mode)
|
| 3 |
Single-file Streamlit app.
|
| 4 |
|
| 5 |
+
Features:
|
| 6 |
+
- Shows allowable stress in the same units selected (MPa for SI, psi for Imperial).
|
| 7 |
+
- Sketch draws the actual tube centers inside the tubesheet circle using the chosen pitch and layout (square/triangular).
|
| 8 |
+
- Option for tube-side passes (affects reporting only — user should input appropriate tube-side pressure per pass if needed).
|
| 9 |
+
- Calculation follows ASME-style flat-plate bending approximation; TEMA option available as illustrative.
|
| 10 |
+
- BWG selection and manual thickness entry retained.
|
| 11 |
+
- Minimum manufacturing thickness removed per request.
|
| 12 |
+
|
| 13 |
+
Notes:
|
| 14 |
+
- Bundled material allowable stresses are illustrative. Verify with ASME Section II for production.
|
| 15 |
+
- TEMA uses illustrative modifiers — consult TEMA documentation for strict compliance.
|
| 16 |
+
Author: ChatGPT
|
| 17 |
+
Updated: 2025-09-07
|
| 18 |
"""
|
| 19 |
|
| 20 |
import streamlit as st
|
|
|
|
| 30 |
|
| 31 |
# -----------------------
|
| 32 |
# Bundled (example) material table (sample values)
|
| 33 |
+
# NOTE: illustrative only — replace with ASME Section II data for production
|
| 34 |
MATERIAL_LOOKUP = {
|
| 35 |
"SA-516 Gr 70": [
|
| 36 |
{"temperature_C": 20, "allowable_MPa": 138},
|
|
|
|
| 121 |
|
| 122 |
# -----------------------
|
| 123 |
# BWG table (typical reference values in inches)
|
|
|
|
|
|
|
| 124 |
BWG_TO_INCH = {
|
| 125 |
"BWG 7": 0.180,
|
| 126 |
"BWG 8": 0.165,
|
|
|
|
| 148 |
|
| 149 |
# -----------------------
|
| 150 |
# Sidebar — Inputs
|
|
|
|
| 151 |
with st.sidebar:
|
| 152 |
st.header("Inputs")
|
| 153 |
project_name = st.text_input("Project name", value="Tubesheet Calculation")
|
|
|
|
| 171 |
|
| 172 |
effective_radius_override = st.checkbox("Override effective calculation radius (optional)")
|
| 173 |
if effective_radius_override:
|
| 174 |
+
eff_radius = st.number_input(
|
| 175 |
+
f"Effective radius ({u_len})",
|
| 176 |
+
min_value=1.0 if use_SI else 0.01,
|
| 177 |
+
value=(OD_ts/2.0),
|
| 178 |
+
step=1.0 if not use_SI else 0.01,
|
| 179 |
+
format="%.3f"
|
| 180 |
+
)
|
| 181 |
else:
|
| 182 |
eff_radius = None
|
| 183 |
|
| 184 |
st.subheader("Tube field")
|
| 185 |
N_tubes = st.number_input("Number of tubes (N)", min_value=1, value=100, step=1)
|
| 186 |
|
| 187 |
+
# Allow small tube ODs: set min depending on units.
|
| 188 |
if use_SI:
|
| 189 |
tube_od_min = 0.5 # mm
|
| 190 |
tube_od_default = 25.4 # mm
|
|
|
|
| 208 |
selected_bwg = None
|
| 209 |
t_tube = None
|
| 210 |
if thickness_input_mode == "Select BWG gauge":
|
|
|
|
| 211 |
bwg_options = list(BWG_TO_INCH.keys())
|
| 212 |
+
selected_bwg = st.selectbox("BWG gauge", options=bwg_options, index=12)
|
|
|
|
| 213 |
thickness_inch = BWG_TO_INCH[selected_bwg]
|
| 214 |
if use_SI:
|
| 215 |
t_tube = inchtomm(thickness_inch)
|
| 216 |
+
st.write(f"Selected {selected_bwg} → wall thickness = {t_tube:.3f} mm (from {thickness_inch:.4f} in)")
|
| 217 |
else:
|
| 218 |
t_tube = thickness_inch
|
| 219 |
st.write(f"Selected {selected_bwg} → wall thickness = {t_tube:.4f} in")
|
| 220 |
else:
|
|
|
|
| 221 |
if use_SI:
|
| 222 |
t_tube_min = 0.1
|
| 223 |
t_tube_default = 1.0
|
|
|
|
| 237 |
|
| 238 |
layout = st.selectbox("Tube pitch layout", options=["Triangular", "Square"])
|
| 239 |
|
| 240 |
+
# default pitch based on OD_tube; user can edit
|
| 241 |
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)
|
| 242 |
pitch = st.number_input(f"Tube pitch ({u_len})",
|
| 243 |
min_value=max(OD_tube * 1.0, tube_od_min),
|
|
|
|
| 246 |
format=tube_od_format)
|
| 247 |
|
| 248 |
st.subheader("Pressure & Temperature")
|
|
|
|
| 249 |
if use_SI:
|
| 250 |
P_shell = st.number_input(f"Design shell-side pressure ({u_press})", value=3.0, step=0.1)
|
| 251 |
P_tube = st.number_input(f"Design tube-side pressure ({u_press})", value=1.0, step=0.1)
|
|
|
|
| 261 |
material_list = ["SA-516 Gr 70", "SA-240 Type 304", "SA-240 Type 316", "SA-105", "SA-36", "SA-387 Grade 11", "Other / Manual"]
|
| 262 |
material = st.selectbox("Tubesheet material (ASME II examples)", options=material_list)
|
| 263 |
|
| 264 |
+
# Determine default usable temperature for lookup (°C)
|
| 265 |
if use_SI:
|
| 266 |
T_shell_C = float(T_shell)
|
| 267 |
T_tube_C = float(T_tube)
|
|
|
|
| 276 |
|
| 277 |
st.write("Allowable stress lookup (ASME Sec II - example table)")
|
| 278 |
if S_allowable_auto is not None:
|
| 279 |
+
if use_SI:
|
| 280 |
+
st.write(f"Auto lookup allowed stress: **{S_allowable_auto:.1f} MPa** at {use_temp_for_lookup:.1f} °C (interpolated)")
|
| 281 |
+
else:
|
| 282 |
+
st.write(f"Auto lookup allowed stress: **{mpato_psi(S_allowable_auto):.1f} psi** at {use_temp_for_lookup:.1f} °C (interpolated)")
|
| 283 |
else:
|
| 284 |
st.write("No auto-lookup available for selected material & temperature in bundled table.")
|
| 285 |
|
| 286 |
+
S_override = st.checkbox("Manual override allowable stress")
|
| 287 |
+
# prepare both vars so later code can rely on them
|
| 288 |
+
S_allowable_manual = None
|
| 289 |
+
S_allowable_manual_psi = None
|
| 290 |
if S_override:
|
| 291 |
+
if use_SI:
|
| 292 |
+
default_S = S_allowable_auto if S_allowable_auto is not None else 100.0
|
| 293 |
+
S_allowable_manual = st.number_input("S_allowable (MPa)", min_value=1.0, value=float(default_S), step=1.0)
|
| 294 |
+
else:
|
| 295 |
+
default_S_psi = mpato_psi(S_allowable_auto) if S_allowable_auto is not None else mpato_psi(100.0)
|
| 296 |
+
S_allowable_manual_psi = st.number_input("S_allowable (psi)", min_value=1.0, value=float(default_S_psi), step=1.0)
|
| 297 |
|
| 298 |
st.subheader("Allowances & Options")
|
| 299 |
if use_SI:
|
|
|
|
| 305 |
|
| 306 |
edge_condition = st.selectbox("Edge condition (plate support)", options=["Clamped (conservative)", "Simply supported (less conservative)"])
|
| 307 |
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)
|
| 308 |
+
|
| 309 |
+
st.subheader("Calculation basis")
|
| 310 |
+
calc_basis = st.selectbox("Calculation method", options=["ASME (default)", "TEMA (illustrative)"])
|
| 311 |
+
|
| 312 |
+
st.subheader("Extra options")
|
| 313 |
+
passes = st.number_input("Number of tube-side passes", min_value=1, value=1, step=1)
|
| 314 |
+
|
| 315 |
show_detail = st.checkbox("Show detailed calculations (expandable)", value=False)
|
| 316 |
compute = st.button("Compute")
|
| 317 |
|
| 318 |
# -----------------------
|
| 319 |
+
# Helper: generate tube grid (square or triangular) inside a circle
|
| 320 |
+
def generate_tube_centers(radius_mm, pitch_mm, layout="Triangular", tube_OD_mm=0.0, max_count=None):
|
| 321 |
+
"""
|
| 322 |
+
radius_mm: circle radius
|
| 323 |
+
pitch_mm: center-to-center pitch
|
| 324 |
+
layout: 'Triangular' or 'Square'
|
| 325 |
+
tube_OD_mm: used to ensure tube edges don't cross the circle (center must be <= radius - tube_OD/2)
|
| 326 |
+
returns: list of (x_mm, y_mm)
|
| 327 |
+
"""
|
| 328 |
+
centers = []
|
| 329 |
+
R = radius_mm
|
| 330 |
+
if pitch_mm <= 0:
|
| 331 |
+
return centers
|
| 332 |
+
|
| 333 |
+
if layout == "Square":
|
| 334 |
+
xs = np.arange(-R, R + 1e-8, pitch_mm)
|
| 335 |
+
ys = np.arange(-R, R + 1e-8, pitch_mm)
|
| 336 |
+
for x in xs:
|
| 337 |
+
for y in ys:
|
| 338 |
+
if x**2 + y**2 <= (R - tube_OD_mm/2.0)**2:
|
| 339 |
+
centers.append((x, y))
|
| 340 |
+
if max_count and len(centers) >= max_count:
|
| 341 |
+
return centers
|
| 342 |
+
else:
|
| 343 |
+
vert = pitch_mm * math.sqrt(3)/2.0
|
| 344 |
+
# construct rows symmetric about y=0
|
| 345 |
+
# start from center row and go outwards to cover -R..R
|
| 346 |
+
row_offsets = []
|
| 347 |
+
y_center = 0.0
|
| 348 |
+
row_offsets.append(y_center)
|
| 349 |
+
step = vert
|
| 350 |
+
n = 1
|
| 351 |
+
while True:
|
| 352 |
+
y_up = n * step
|
| 353 |
+
y_down = -n * step
|
| 354 |
+
if abs(y_up) <= R or abs(y_down) <= R:
|
| 355 |
+
if abs(y_up) <= R:
|
| 356 |
+
row_offsets.append(y_up)
|
| 357 |
+
if abs(y_down) <= R:
|
| 358 |
+
row_offsets.append(y_down)
|
| 359 |
+
n += 1
|
| 360 |
+
else:
|
| 361 |
+
break
|
| 362 |
+
row_offsets = sorted(set(row_offsets))
|
| 363 |
+
for idx, y in enumerate(row_offsets):
|
| 364 |
+
if idx % 2 == 0:
|
| 365 |
+
xs = np.arange(-R, R + 1e-8, pitch_mm)
|
| 366 |
+
else:
|
| 367 |
+
xs = np.arange(-R + pitch_mm/2.0, R + 1e-8, pitch_mm)
|
| 368 |
+
for x in xs:
|
| 369 |
+
if x**2 + y**2 <= (R - tube_OD_mm/2.0)**2:
|
| 370 |
+
centers.append((x, y))
|
| 371 |
+
if max_count and len(centers) >= max_count:
|
| 372 |
+
return centers
|
| 373 |
+
return centers
|
| 374 |
+
|
| 375 |
# -----------------------
|
| 376 |
+
# Calculation logic
|
| 377 |
def compute_all():
|
| 378 |
# Unit conversions into consistent internal units:
|
| 379 |
# lengths in mm, pressure in MPa, stresses in MPa
|
|
|
|
| 383 |
pitch_mm = float(pitch)
|
| 384 |
CA_mm = float(CA)
|
| 385 |
machining_mm = float(machining)
|
| 386 |
+
t_tube_mm_input = float(t_tube)
|
|
|
|
| 387 |
P_shell_MPa = bartompa(float(P_shell))
|
| 388 |
P_tube_MPa = bartompa(float(P_tube))
|
| 389 |
T_shellC = float(T_shell)
|
|
|
|
| 394 |
pitch_mm = inchtomm(float(pitch))
|
| 395 |
CA_mm = inchtomm(float(CA))
|
| 396 |
machining_mm = inchtomm(float(machining))
|
|
|
|
| 397 |
t_tube_mm_input = inchtomm(float(t_tube))
|
| 398 |
P_shell_MPa = psito_mpa(float(P_shell))
|
| 399 |
P_tube_MPa = psito_mpa(float(P_tube))
|
|
|
|
| 414 |
q = deltaP_MPa # MPa (N/mm^2) as a uniform load approximation
|
| 415 |
|
| 416 |
# allowable stress selection
|
| 417 |
+
if S_override:
|
| 418 |
+
if use_SI:
|
| 419 |
+
S_allow_MPa = float(S_allowable_manual)
|
| 420 |
+
else:
|
| 421 |
+
# ensure psi->MPa conversion
|
| 422 |
+
S_allow_MPa = psito_mpa(float(S_allowable_manual_psi))
|
| 423 |
source_S = "Manual override"
|
| 424 |
else:
|
| 425 |
S_allow_MPa = S_allowable_auto if S_allowable_auto is not None else None
|
| 426 |
source_S = "Auto lookup (interpolated)" if S_allowable_auto is not None else "None available"
|
| 427 |
|
| 428 |
+
# fallback if none available
|
| 429 |
if S_allow_MPa is None:
|
| 430 |
S_allow_MPa = 100.0
|
| 431 |
source_S = "Default used (no lookup) - user must verify"
|
| 432 |
|
| 433 |
+
# choose k based on edge condition and calculation basis
|
| 434 |
+
if calc_basis.startswith("ASME"):
|
| 435 |
+
if edge_condition.startswith("Clamped"):
|
| 436 |
+
k = 0.308
|
| 437 |
+
else:
|
| 438 |
+
k = 0.375
|
| 439 |
else:
|
| 440 |
+
# TEMA illustrative modifiers (verify with TEMA docs)
|
| 441 |
+
if edge_condition.startswith("Clamped"):
|
| 442 |
+
k = 0.27
|
| 443 |
+
else:
|
| 444 |
+
k = 0.33
|
| 445 |
|
| 446 |
SF = float(safety_factor)
|
| 447 |
|
|
|
|
| 454 |
else:
|
| 455 |
t_req_mm = math.sqrt(max(numerator / denom, 0.0))
|
| 456 |
|
| 457 |
+
# determine tube centers using pitch and layout (limit to user N_tubes)
|
| 458 |
+
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)
|
| 459 |
+
actual_N = len(centers)
|
| 460 |
# hole area and percent removed
|
| 461 |
+
hole_area_mm2 = actual_N * math.pi * (OD_tube_mm / 2.0) ** 2
|
| 462 |
plate_area_mm2 = math.pi * (a_mm ** 2)
|
| 463 |
+
hole_removed_pct = (hole_area_mm2 / plate_area_mm2) * 100.0 if plate_area_mm2 > 0 else 0.0
|
| 464 |
|
| 465 |
# local bearing / ligament check (simple)
|
| 466 |
net_area_mm2 = max(plate_area_mm2 - hole_area_mm2, 1.0)
|
|
|
|
| 478 |
"t_final_unrounded_mm": t_final_unrounded_mm,
|
| 479 |
"t_final_rounded_mm": t_final_rounded_mm,
|
| 480 |
"S_allow_MPa": S_allow_MPa,
|
| 481 |
+
"S_allow_display": f"{S_allow_MPa:.2f} MPa",
|
| 482 |
"deltaP_MPa": deltaP_MPa,
|
| 483 |
+
"deltaP_display": f"{deltaP_MPa:.4f} MPa",
|
| 484 |
"hole_removed_pct": hole_removed_pct,
|
| 485 |
"avg_membrane_stress_approx_MPa": avg_membrane_stress_approx,
|
| 486 |
+
"avg_membrane_stress_approx_display": f"{avg_membrane_stress_approx:.3f} MPa",
|
| 487 |
"bearing_flag": bearing_flag,
|
| 488 |
"a_mm": a_mm,
|
| 489 |
"t_tube_mm_input": t_tube_mm_input,
|
| 490 |
+
"actual_N": actual_N,
|
| 491 |
"source_S": source_S
|
| 492 |
}
|
| 493 |
else:
|
|
|
|
| 496 |
"t_final_unrounded_in": mmtoinch(t_final_unrounded_mm),
|
| 497 |
"t_final_rounded_in": round_up_standard_in(mmtoinch(t_final_rounded_mm)),
|
| 498 |
"S_allow_MPa": S_allow_MPa,
|
| 499 |
+
"S_allow_display": f"{mpato_psi(S_allow_MPa):.1f} psi",
|
| 500 |
"deltaP_MPa": deltaP_MPa,
|
| 501 |
+
"deltaP_display": f"{mpato_psi(deltaP_MPa):.2f} psi",
|
| 502 |
"hole_removed_pct": hole_removed_pct,
|
| 503 |
"avg_membrane_stress_approx_MPa": avg_membrane_stress_approx,
|
| 504 |
+
"avg_membrane_stress_approx_display": f"{mpato_psi(avg_membrane_stress_approx):.2f} psi",
|
| 505 |
"bearing_flag": bearing_flag,
|
| 506 |
"a_in": mmtoinch(a_mm),
|
| 507 |
"t_tube_in_input": mmtoinch(t_tube_mm_input),
|
| 508 |
+
"actual_N": actual_N,
|
| 509 |
"source_S": source_S
|
| 510 |
}
|
| 511 |
|
|
|
|
| 516 |
log_lines.append("---- Units (internal calculations): lengths=mm, pressures=MPa, stress=MPa ----")
|
| 517 |
log_lines.append(f"Selected units: {units}")
|
| 518 |
log_lines.append(f"OD_ts = {OD_ts} {u_len} -> a = {a_mm:.2f} mm")
|
| 519 |
+
log_lines.append(f"Requested N_tubes = {N_tubes}, Actual placed = {actual_N}, OD_tube = {OD_tube} {u_len} -> OD_tube_mm = {OD_tube_mm:.6f} mm")
|
| 520 |
log_lines.append(f"Tube wall thickness input mode = {thickness_input_mode}")
|
| 521 |
if thickness_input_mode == "Select BWG":
|
| 522 |
log_lines.append(f"Selected BWG = {selected_bwg}, t_tube = {t_tube_mm_input:.4f} mm")
|
|
|
|
| 525 |
log_lines.append(f"Pressures: P_shell = {P_shell} {u_press} ({P_shell_MPa:.6f} MPa), P_tube = {P_tube} {u_press} ({P_tube_MPa:.6f} MPa)")
|
| 526 |
log_lines.append(f"ΔP (governing) = |P_shell - P_tube| = {deltaP_MPa:.6f} MPa")
|
| 527 |
log_lines.append(f"Material: {material} (S source: {source_S}), S_used = {S_allow_MPa:.3f} MPa")
|
| 528 |
+
log_lines.append(f"Calculation basis: {calc_basis}, Edge condition k = {k}, safety multiplier SF = {SF}")
|
| 529 |
log_lines.append(f"Plate bending formula used: t_req = sqrt( (q * a^2) / (k * S * SF) )")
|
| 530 |
log_lines.append(f"Substitution: q={q:.6f} MPa, a={a_mm:.3f} mm, k={k}, S={S_allow_MPa:.3f} MPa, SF={SF}")
|
| 531 |
log_lines.append(f"Computed t_req = {t_req_mm:.3f} mm")
|
|
|
|
| 541 |
# produce summary table (pandas)
|
| 542 |
summary = []
|
| 543 |
summary.append(["Tubesheet OD", f"{OD_ts} {u_len}"])
|
| 544 |
+
summary.append(["Number of tubes (requested)", f"{N_tubes}"])
|
| 545 |
+
summary.append(["Number of tubes (placed)", f"{actual_N}"])
|
| 546 |
summary.append(["Tube OD", f"{OD_tube} {u_len}"])
|
| 547 |
if use_SI:
|
| 548 |
summary.append(["Tube wall thickness (input)", f"{t_tube_mm_input:.3f} mm"])
|
| 549 |
else:
|
| 550 |
summary.append(["Tube wall thickness (input)", f"{mmtoinch(t_tube_mm_input):.4f} in"])
|
| 551 |
summary.append(["Pitch", f"{pitch} {u_len}"])
|
| 552 |
+
summary.append(["Layout", f"{layout}"])
|
| 553 |
summary.append(["Material", material])
|
| 554 |
+
summary.append(["S_used", results['S_allow_display']])
|
| 555 |
+
summary.append(["ΔP", results['deltaP_display']])
|
| 556 |
if use_SI:
|
| 557 |
+
summary.append(["t_req (mm)", f"{results['t_req_mm']:.3f}"])
|
| 558 |
+
summary.append(["t_final_unrounded (mm)", f"{results['t_final_unrounded_mm']:.3f}"])
|
| 559 |
+
summary.append(["t_final_rounded (mm)", f"{results['t_final_rounded_mm']:.3f}"])
|
| 560 |
else:
|
| 561 |
+
summary.append(["t_req (in)", f"{results['t_req_in']:.4f}"])
|
| 562 |
+
summary.append(["t_final_unrounded (in)", f"{results['t_final_unrounded_in']:.4f}"])
|
| 563 |
+
summary.append(["t_final_rounded (in)", f"{results['t_final_rounded_in']:.4f}"])
|
| 564 |
summary.append(["Hole removed (%)", f"{hole_removed_pct:.2f}%"])
|
| 565 |
+
if use_SI:
|
| 566 |
+
summary.append(["Approx avg membrane stress", f"{results['avg_membrane_stress_approx_MPa']:.3f} MPa ({mpato_psi(results['avg_membrane_stress_approx_MPa']):.2f} psi)"])
|
| 567 |
+
else:
|
| 568 |
+
summary.append(["Approx avg membrane stress", f"{results['avg_membrane_stress_approx_display']}"])
|
| 569 |
+
summary.append(["Tube-side passes", f"{passes}"])
|
| 570 |
summary_df = pd.DataFrame(summary, columns=["Parameter", "Value"])
|
| 571 |
|
| 572 |
# CSV export
|
|
|
|
| 574 |
summary_df.to_csv(csv_buffer, index=False)
|
| 575 |
csv_data = csv_buffer.getvalue().encode("utf-8")
|
| 576 |
|
| 577 |
+
# Convert centers into normalized plot coordinates for schematic: scale by a_mm (radius)
|
| 578 |
+
# Plot uses coordinates in range [-1,1]
|
| 579 |
+
plot_centers = [(x / a_mm, y / a_mm) for (x, y) in centers]
|
| 580 |
+
|
| 581 |
+
return results, log_text, summary_df, csv_data, plot_centers, OD_tube_mm, a_mm
|
| 582 |
|
| 583 |
# -----------------------
|
| 584 |
# Run compute and display results
|
|
|
|
| 585 |
if compute:
|
| 586 |
+
results, log_text, summary_df, csv_bytes, plot_centers, OD_tube_mm, a_mm = compute_all()
|
| 587 |
|
| 588 |
# Show KPI cards
|
| 589 |
st.subheader("Results")
|
|
|
|
| 598 |
c1.metric("Required thickness (t_req)", f"{results['t_req_in']:.4f} in")
|
| 599 |
c2.metric("Final thickness (unrounded)", f"{results['t_final_unrounded_in']:.4f} in")
|
| 600 |
c3.metric("Final thickness (rounded)", f"{results['t_final_rounded_in']:.4f} in")
|
| 601 |
+
c4.metric("ΔP", results['deltaP_display'])
|
| 602 |
|
| 603 |
# Summary table and schematic
|
| 604 |
left, right = st.columns([2,3])
|
| 605 |
with left:
|
| 606 |
st.markdown("**Summary table**")
|
| 607 |
st.dataframe(summary_df, use_container_width=True)
|
|
|
|
| 608 |
st.download_button("Download summary CSV", data=csv_bytes, file_name="tubesheet_summary.csv", mime="text/csv")
|
| 609 |
|
| 610 |
with right:
|
| 611 |
+
st.markdown("**Schematic (to scale in layout) — normalized to tubesheet radius**")
|
|
|
|
| 612 |
fig = go.Figure()
|
| 613 |
+
# outer circle (radius = 1 normalized)
|
| 614 |
theta = np.linspace(0, 2*np.pi, 200)
|
| 615 |
fig.add_trace(go.Scatter(x=np.cos(theta), y=np.sin(theta), mode="lines", name="Tubesheet OD",
|
| 616 |
+
line=dict(width=2, color="RoyalBlue")))
|
| 617 |
+
# tubes as markers; scale marker size approx proportional to OD_tube_mm / a_mm
|
| 618 |
+
xs = [c[0] for c in plot_centers]
|
| 619 |
+
ys = [c[1] for c in plot_centers]
|
| 620 |
+
# compute pixel-like marker size but keep within reasonable bounds
|
| 621 |
+
if a_mm > 0:
|
| 622 |
+
# fraction of normalized radius occupied by tube radius
|
| 623 |
+
frac = (OD_tube_mm / 2.0) / a_mm
|
| 624 |
+
# convert fraction to marker size (empirical scaling)
|
| 625 |
+
marker_size = max(4, min(40, frac * 800))
|
| 626 |
+
else:
|
| 627 |
+
marker_size = 6
|
| 628 |
+
fig.add_trace(go.Scatter(x=xs, y=ys, mode="markers", marker=dict(size=marker_size), name="Tube holes"))
|
| 629 |
+
fig.update_layout(width=800, height=800, margin=dict(l=10,r=10,t=30,b=10), showlegend=False,
|
| 630 |
+
xaxis=dict(visible=False, range=[-1.05,1.05]), yaxis=dict(visible=False, range=[-1.05,1.05]),
|
| 631 |
+
title_text="Tubesheet layout (normalized to radius = 1)")
|
| 632 |
st.plotly_chart(fig, use_container_width=True)
|
| 633 |
|
| 634 |
# Calculation log
|
|
|
|
| 638 |
|
| 639 |
# Warnings and notes
|
| 640 |
st.markdown("### Notes & Warnings")
|
| 641 |
+
st.markdown("- This is a **preliminary** sizing tool using a simplified flat-plate approximation (ASME-like plate bending formula).")
|
| 642 |
st.markdown("- The bundled material table is illustrative. **Verify allowable stresses with ASME Section II** for your material and temperature.")
|
| 643 |
+
st.markdown("- The BWG → thickness mapping is provided as a reference. **Confirm exact tube wall thickness with manufacturer/datasheet before final design.**")
|
| 644 |
+
st.markdown("- TEMA option uses illustrative modifiers only — consult TEMA standards if you choose that path.")
|
| 645 |
st.markdown("- Final design must be verified and stamped by a qualified engineer as per ASME Section VIII Division 1.")
|
| 646 |
|
| 647 |
else:
|
|
|
|
| 649 |
|
| 650 |
# -----------------------
|
| 651 |
# Presets (quick-load examples)
|
|
|
|
| 652 |
st.sidebar.markdown("---")
|
| 653 |
st.sidebar.markdown("### Presets")
|
| 654 |
if st.sidebar.button("Example 1 (SI)"):
|
| 655 |
+
st.info("Preset example: OD 1000 mm, ~100 tubes, tube OD 25.4 mm, P_shell=3 bar, P_tube=1 bar — set values manually or copy/paste into fields.")
|
| 656 |
if st.sidebar.button("Example 2 (Imperial)"):
|
| 657 |
+
st.info("Preset example: OD 40 in, ~200 tubes, tube OD 0.75 in, P_shell=50 psi, P_tube=14.7 psi — set values manually or copy/paste into fields.")
|
| 658 |
|
| 659 |
# End of app
|