Umar4321 commited on
Commit
a77ac09
·
verified ·
1 Parent(s): 5973353

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +185 -81
app.py CHANGED
@@ -1,12 +1,20 @@
1
  """
2
- Tubesheet Thickness Calculator (ASME VIII-1 preliminary)
3
  Single-file Streamlit app.
4
 
5
- Updated:
6
- - removed the "minimum manufacturing thickness" input and all uses/display of it.
7
- - kept BWG (Birmingham Wire Gauge) selection for tube wall thickness with a manual override.
8
- - kept small-imperial-fraction support and previous logic.
9
- Note: The tool is preliminary verify values (material allowable stresses, BWG table, allowances) before final design.
 
 
 
 
 
 
 
 
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: These are illustrative. Replace with verified ASME Section II data for production use.
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(f"Effective radius ({u_len})",
170
- min_value=1.0 if use_SI else 0.01,
171
- value=(OD_ts/2.0),
172
- step=1.0 if not use_SI else 0.01,
173
- format="%.3f")
 
 
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. e.g., allow 0.5 mm or 1/64 in
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) # default to some mid gauge
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 (converted from {thickness_inch:.4f} in)")
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
- # compute default pitch based on OD_tube; user can edit
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
- st.write(f"Auto lookup allowed stress: **{S_allowable_auto:.1f} MPa** at {use_temp_for_lookup:.1f} °C (interpolated)")
 
 
 
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 (MPa)")
 
 
 
281
  if S_override:
282
- default_S = S_allowable_auto if S_allowable_auto is not None else 100.0
283
- S_allowable_manual = st.number_input("S_allowable (MPa)", min_value=1.0, value=float(default_S), step=1.0)
284
- else:
285
- S_allowable_manual = None
 
 
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
- # Calculation logic
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # t_tube currently in mm if user selected BWG (converted) or manual in mm
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 and S_allowable_manual is not None:
346
- S_allow_MPa = float(S_allowable_manual)
 
 
 
 
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 S not available, set to a conservative default and warn
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 edge_condition.startswith("Clamped"):
359
- k = 0.308
 
 
 
360
  else:
361
- k = 0.375
 
 
 
 
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 = N_tubes * math.pi * (OD_tube_mm / 2.0) ** 2
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
- "S_allow_psi": mpato_psi(S_allow_MPa),
410
  "deltaP_MPa": deltaP_MPa,
411
- "deltaP_psi": mpato_psi(deltaP_MPa),
412
  "hole_removed_pct": hole_removed_pct,
413
  "avg_membrane_stress_approx_MPa": avg_membrane_stress_approx,
414
- "avg_membrane_stress_approx_psi": mpato_psi(avg_membrane_stress_approx),
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 (MPa)", f"{S_allow_MPa:.3f}"])
462
- summary.append(["ΔP", f"{deltaP_MPa:.6f} MPa ({mpato_psi(deltaP_MPa):.2f} psi)"])
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"{mmtoinch(t_req_mm):.4f}"])
469
- summary.append(["t_final_unrounded (in)", f"{mmtoinch(t_final_unrounded_mm):.4f}"])
470
- summary.append(["t_final_rounded (in)", f"{round_up_standard_in(mmtoinch(t_final_rounded_mm)):.4f}"])
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
- return results, log_text, summary_df, csv_data
 
 
 
 
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", f"{results['deltaP_psi']:.2f} psi")
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 (not to scale)**")
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
- n_mark = min(300, int(N_tubes))
518
- xs = []
519
- ys = []
520
- r = 0.85
521
- for i in range(n_mark):
522
- ang = 2*np.pi*i / n_mark
523
- xs.append(0.5 * r * np.cos(ang))
524
- ys.append(0.5 * r * np.sin(ang))
525
- fig.add_trace(go.Scatter(x=xs, y=ys, mode="markers", marker=dict(size=4, color="DarkOrange"), name="Tube holes"))
526
- fig.update_layout(width=600, height=600, margin=dict(l=10,r=10,t=10,b=10), showlegend=False,
527
- xaxis=dict(visible=False), yaxis=dict(visible=False), title_text="Tubesheet schematic (not to scale)")
 
 
 
 
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 (clamped or simply supported).")
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 common reference. **Confirm exact tube wall thickness with manufacturer or datasheet before final design.**")
 
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.write("Preset example: OD 1000 mm, 100 tubes, tube OD 25.4 mm, P_shell=3 bar, P_tube=1 bar.")
552
  if st.sidebar.button("Example 2 (Imperial)"):
553
- st.write("Preset example: OD 40 in, 200 tubes, tube OD 0.75 in, P_shell=50 psi, P_tube=14.7 psi")
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