File size: 27,440 Bytes
44d4848
a4272b3
44d4848
 
a4272b3
 
 
 
 
 
 
44d4848
 
 
 
 
 
 
 
 
 
a4272b3
 
44d4848
 
a4272b3
 
44d4848
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4272b3
9997706
 
 
 
 
 
 
 
44d4848
 
9997706
44d4848
 
 
 
9997706
 
 
44d4848
 
 
 
 
 
9997706
 
44d4848
 
5973353
a4272b3
5973353
9997706
 
 
 
 
 
5973353
 
44d4848
a4272b3
44d4848
 
 
 
9997706
0a173c8
9997706
a4272b3
 
 
44d4848
 
0a173c8
 
 
 
 
44d4848
 
a77ac09
 
 
 
 
 
 
44d4848
 
 
 
a4272b3
0a173c8
a4272b3
0a173c8
a4272b3
 
0a173c8
5973353
0a173c8
a4272b3
 
0a173c8
5973353
0a173c8
a4272b3
 
 
 
 
5973353
 
 
 
 
 
 
a77ac09
5973353
 
 
a77ac09
5973353
 
 
0a173c8
5973353
a4272b3
5973353
a4272b3
 
 
9997706
 
 
 
a4272b3
 
9997706
0a173c8
 
a4272b3
44d4848
 
0a173c8
 
 
 
 
 
 
 
 
 
44d4848
 
a4272b3
44d4848
0a173c8
a4272b3
0a173c8
44d4848
 
 
 
 
a4272b3
44d4848
 
 
 
a4272b3
44d4848
a77ac09
a4272b3
a77ac09
a4272b3
44d4848
 
 
a77ac09
 
 
44d4848
a77ac09
 
 
 
 
 
44d4848
 
0a173c8
 
 
 
 
 
 
44d4848
 
a77ac09
 
a4272b3
 
 
 
 
 
 
 
a77ac09
 
a4272b3
a77ac09
44d4848
 
 
 
a4272b3
9997706
a4272b3
9997706
 
 
a77ac09
 
9997706
a77ac09
 
9997706
a77ac09
 
 
 
 
 
 
 
9997706
 
 
 
a4272b3
9997706
a4272b3
9997706
a4272b3
9997706
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4272b3
9997706
 
 
 
 
a4272b3
9997706
a4272b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9997706
a77ac09
 
44d4848
a77ac09
44d4848
a4272b3
0a173c8
44d4848
 
 
 
 
a77ac09
44d4848
 
 
 
 
 
 
 
5973353
44d4848
 
 
 
 
0a173c8
44d4848
 
 
 
 
 
a4272b3
 
 
 
 
44d4848
a4272b3
a77ac09
 
 
 
 
44d4848
 
 
 
 
 
 
 
 
a4272b3
a77ac09
a4272b3
44d4848
a4272b3
 
44d4848
 
 
a4272b3
 
44d4848
a4272b3
44d4848
a4272b3
44d4848
a4272b3
44d4848
a4272b3
a77ac09
 
 
44d4848
a4272b3
 
 
 
 
 
 
 
 
 
 
44d4848
a4272b3
 
 
44d4848
a4272b3
 
 
44d4848
 
a4272b3
0a173c8
44d4848
a4272b3
 
44d4848
 
 
a77ac09
44d4848
a77ac09
a4272b3
 
 
0a173c8
5973353
0a173c8
44d4848
 
 
a4272b3
 
44d4848
5973353
44d4848
a77ac09
44d4848
a77ac09
a4272b3
 
 
0a173c8
5973353
0a173c8
44d4848
 
a4272b3
44d4848
 
 
a4272b3
44d4848
a4272b3
 
 
 
44d4848
a4272b3
 
 
 
 
 
 
 
 
 
 
 
 
44d4848
 
a4272b3
44d4848
 
a77ac09
 
44d4848
5973353
 
 
 
44d4848
a77ac09
44d4848
a77ac09
 
0a173c8
a4272b3
 
a77ac09
 
44d4848
a4272b3
 
a77ac09
 
a4272b3
 
a77ac09
44d4848
 
 
 
 
 
a4272b3
a77ac09
 
 
44d4848
 
a4272b3
44d4848
a77ac09
44d4848
a4272b3
0a173c8
44d4848
a4272b3
 
 
 
44d4848
 
a4272b3
 
44d4848
a4272b3
44d4848
 
 
 
 
 
 
 
a4272b3
44d4848
9997706
a4272b3
 
 
a77ac09
a4272b3
a77ac09
 
 
 
a4272b3
 
a77ac09
 
44d4848
a4272b3
9997706
 
 
a4272b3
 
9997706
44d4848
 
 
 
 
a4272b3
 
 
44d4848
 
a4272b3
44d4848
 
a4272b3
44d4848
 
0a173c8
a4272b3
0a173c8
a4272b3
 
44d4848
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
"""
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