import streamlit as st import pandas as pd import numpy as np import joblib import math # ============================================================ # PAGE CONFIG # ============================================================ st.set_page_config( page_title="HTRI Heat Exchanger Predictor", page_icon="🔥", layout="wide" ) # ============================================================ # LOAD MODEL BUNDLE # ============================================================ @st.cache_resource def load_models(): bundle = joblib.load("htri_models_v4.pkl") return bundle try: bundle = load_models() model_multi = bundle["model_multi"] model_length = bundle["model_length"] model_qty = bundle["model_qty"] except Exception as e: st.error(f"Failed to load model: {e}") st.stop() # ============================================================ # ROUNDING HELPERS # ============================================================ def round_to_nearest(value, multiple): """Round value to nearest multiple.""" return round(round(value / multiple) * multiple, 10) def round_shell_od(value_in): """Round Shell OD to nearest 2 inches.""" return int(round_to_nearest(value_in, 2)) def round_tube_od(value_in): """Round Tube OD to nearest 1/8 inch.""" return round(round_to_nearest(value_in, 0.125), 4) def round_tube_length(value_ft): """Round Tube Length to nearest whole foot.""" return int(round(value_ft)) # ============================================================ # HEADER # ============================================================ st.title("🔥 HTRI Heat Exchanger Geometry Predictor") st.markdown( "Predict shell-and-tube heat exchanger geometry using a " "trained XGBoost ML model. Enter process conditions and " "get predicted sizing outputs instantly." ) st.divider() # ============================================================ # UNIT TOGGLE # ============================================================ col_toggle, _ = st.columns([1, 3]) with col_toggle: unit_system = st.radio( "Input Unit System", options=["US Customary", "SI / Metric"], horizontal=True ) si_mode = unit_system == "SI / Metric" st.divider() # ============================================================ # INPUT FORM # ============================================================ st.subheader("Process Inputs") col1, col2, col3 = st.columns(3) # ============================================================ # COLUMN 1 — FLOW RATES + HEAT DUTY # ============================================================ with col1: st.markdown("**Flow Rates**") if si_mode: shell_flow = st.number_input( "Shell Side Flow Rate (kg/hr)", min_value=0.0, value=166000.0, step=100.0, format="%.1f" ) tube_flow = st.number_input( "Tube Side Flow Rate (kg/hr)", min_value=0.0, value=112000.0, step=100.0, format="%.1f" ) else: shell_flow = st.number_input( "Shell Side Flow Rate (lb/hr)", min_value=0.0, value=366000.0, step=100.0, format="%.1f" ) tube_flow = st.number_input( "Tube Side Flow Rate (lb/hr)", min_value=0.0, value=247000.0, step=100.0, format="%.1f" ) st.markdown("**Heat Duty**") if si_mode: heat_duty = st.number_input( "Heat Duty (kW)", min_value=0.0, value=5570.0, step=10.0, format="%.1f" ) else: heat_duty = st.number_input( "Heat Duty (Btu/hr)", min_value=0.0, value=19000000.0, step=10000.0, format="%.1f" ) # ============================================================ # COLUMN 2 — THERMAL + PASSES + TUBE GEOMETRY # ============================================================ with col2: st.markdown("**Thermal**") if si_mode: lmtd = st.number_input( "Corrected LMTD (°C)", min_value=0.0, value=109.4, step=0.1, format="%.2f" ) else: lmtd = st.number_input( "Corrected LMTD (°F)", min_value=0.0, value=196.9, step=0.1, format="%.2f" ) st.markdown("**Passes**") shell_passes = st.number_input( "Shell Passes", min_value=1, max_value=6, value=1, step=1 ) tube_passes = st.number_input( "Tube Passes", min_value=1, max_value=16, value=4, step=1 ) st.markdown("**Tube Geometry**") if si_mode: tube_pitch = st.number_input( "Tube Pitch (mm)", min_value=1.0, value=25.4000, step=0.0001, format="%.4f" ) else: tube_pitch = st.number_input( "Tube Pitch (inch)", min_value=0.1, value=1.0000, step=0.0001, format="%.4f" ) tube_layout = st.selectbox( "Tube Layout Angle (°)", options=[30, 45, 60, 90], index=3 ) # ============================================================ # COLUMN 3 — PRESSURE DROPS + VELOCITIES # ============================================================ with col3: st.markdown("**Pressure Drops**") if si_mode: shell_dp = st.number_input( "Shell Side ΔP (kPa)", min_value=0.0, value=3.63, step=0.01, format="%.3f" ) tube_dp = st.number_input( "Tube Side ΔP (kPa)", min_value=0.0, value=6.87, step=0.01, format="%.3f" ) else: shell_dp = st.number_input( "Shell Side ΔP (psi)", min_value=0.0, value=0.526, step=0.001, format="%.3f" ) tube_dp = st.number_input( "Tube Side ΔP (psi)", min_value=0.0, value=0.996, step=0.001, format="%.3f" ) st.markdown("**Velocities**") if si_mode: shell_vel = st.number_input( "Shell Side Velocity (m/s)", min_value=0.0, value=0.616, step=0.001, format="%.3f" ) tube_vel = st.number_input( "Tube Side Velocity (m/s)", min_value=0.0, value=0.594, step=0.001, format="%.3f" ) else: shell_vel = st.number_input( "Shell Side Velocity (ft/s)", min_value=0.0, value=2.020, step=0.001, format="%.3f" ) tube_vel = st.number_input( "Tube Side Velocity (ft/s)", min_value=0.0, value=1.950, step=0.001, format="%.3f" ) st.divider() # ============================================================ # CONVERT ALL INPUTS TO US CUSTOMARY FOR MODEL # ============================================================ if si_mode: shell_flow_us = shell_flow * 2.20462 tube_flow_us = tube_flow * 2.20462 heat_duty_us = heat_duty * 3412.14 lmtd_us = (lmtd * 1.8) + 32 tube_pitch_us = tube_pitch / 25.4 shell_dp_us = shell_dp * 0.145038 tube_dp_us = tube_dp * 0.145038 shell_vel_us = shell_vel * 3.28084 tube_vel_us = tube_vel * 3.28084 else: shell_flow_us = shell_flow tube_flow_us = tube_flow heat_duty_us = heat_duty lmtd_us = lmtd tube_pitch_us = tube_pitch shell_dp_us = shell_dp tube_dp_us = tube_dp shell_vel_us = shell_vel tube_vel_us = tube_vel # ============================================================ # PREDICT BUTTON # ============================================================ predict = st.button("🚀 Predict Geometry", type="primary", use_container_width=True) if predict: # -------------------------------------------------------- # BUILD INPUT DATAFRAME # -------------------------------------------------------- input_data = pd.DataFrame([{ "Shell_Flow_lb_hr" : shell_flow_us, "Tube_Flow_lb_hr" : tube_flow_us, "Heat_Duty_Btu_hr" : heat_duty_us, "LMTD_F" : lmtd_us, "Shell_Passes" : shell_passes, "Tube_Passes" : tube_passes, "Tube_Pitch_in" : tube_pitch_us, "Tube_Layout_Angle" : tube_layout, "Shell_DP_psi" : shell_dp_us, "Tube_DP_psi" : tube_dp_us, "Shell_Velocity_ft_s": shell_vel_us, "Tube_Velocity_ft_s" : tube_vel_us, }]) # -------------------------------------------------------- # STEP 1 — Predict Shell_OD + Tube_OD (MultiOutput) # -------------------------------------------------------- multi_pred = model_multi.predict(input_data) shell_od_raw = float(multi_pred[0, 0]) tube_od_raw = float(multi_pred[0, 1]) # -------------------------------------------------------- # STEP 2 — Build chained input (raw values for model) # -------------------------------------------------------- input_chained = input_data.copy() input_chained["Shell_OD_in"] = shell_od_raw input_chained["Tube_OD_in"] = tube_od_raw # -------------------------------------------------------- # STEP 3 — Predict Tube_Length + Tube_Quantity # -------------------------------------------------------- tube_length_raw = float(model_length.predict(input_chained)[0]) tube_qty_raw = float(model_qty.predict(input_chained)[0]) # -------------------------------------------------------- # APPLY ROUNDING RULES # Shell OD -> nearest 2 in # Tube OD -> nearest 1/8 in # Tube Length -> nearest whole foot # Tube Qty -> nearest whole number # -------------------------------------------------------- shell_od_in = round_shell_od(shell_od_raw) # int, multiple of 2 tube_od_in = round_tube_od(tube_od_raw) # float, multiple of 0.125 tube_len_ft = round_tube_length(tube_length_raw) # int, whole feet tube_qty = int(round(tube_qty_raw)) # int # SI conversions of rounded values shell_od_mm = round(shell_od_in * 25.4, 1) tube_od_mm = round(tube_od_in * 25.4, 4) tube_len_m = round(tube_len_ft * 0.3048, 3) # ±15% bounds shell_od_lo = round_shell_od(shell_od_raw * 0.85) shell_od_hi = round_shell_od(shell_od_raw * 1.15) tube_len_lo = round_tube_length(tube_length_raw * 0.85) tube_len_hi = round_tube_length(tube_length_raw * 1.15) tube_qty_lo = int(round(tube_qty_raw * 0.85)) tube_qty_hi = int(round(tube_qty_raw * 1.15)) # -------------------------------------------------------- # RESULTS # -------------------------------------------------------- st.subheader("📊 Predicted Geometry") # ---- High confidence ---- st.markdown("#### ✅ High Confidence") st.caption("R² > 0.89 — reliable for preliminary sizing") r1, r2 = st.columns(2) with r1: if si_mode: st.metric( "Shell OD", f"{shell_od_mm} mm", delta=f"Raw: {round(shell_od_raw * 25.4, 1)} mm → rounded to nearest 50.8 mm (2 in)" ) else: st.metric( "Shell OD", f"{shell_od_in} in", delta=f"Raw: {round(shell_od_raw, 3)} in → rounded to nearest 2 in" ) with r2: if si_mode: st.metric( "Tube OD", f"{tube_od_mm} mm", delta=f"Raw: {round(tube_od_raw * 25.4, 4)} mm → rounded to nearest 3.175 mm (1/8 in)" ) else: st.metric( "Tube OD", f"{tube_od_in} in", delta=f"Raw: {round(tube_od_raw, 4)} in → rounded to nearest 1/8 in" ) st.divider() # ---- Moderate confidence ---- st.markdown("#### ⚠️ Moderate Confidence") st.warning( "**Tube Length and Tube Quantity carry ±15% uncertainty** " "based on model validation (R² ≈ 0.79–0.82). " "Use these as a preliminary starting point and verify " "against your required heat transfer area." ) r3, r4 = st.columns(2) with r3: if si_mode: st.metric( "Tube Length", f"{tube_len_m} m ({tube_len_ft} ft)", delta=f"±15% → {round(tube_len_lo * 0.3048, 2)} – {round(tube_len_hi * 0.3048, 2)} m" ) else: st.metric( "Tube Length", f"{tube_len_ft} ft", delta=f"±15% → {tube_len_lo} – {tube_len_hi} ft" ) with r4: st.metric( "Tube Quantity", f"{tube_qty} tubes", delta=f"±15% → {tube_qty_lo} – {tube_qty_hi} tubes" ) st.divider() # ---- Full summary table ---- st.markdown("#### 📋 Full Summary") summary = pd.DataFrame({ "Output": [ "Shell OD", "Tube OD", "Tube Length", "Tube Quantity", ], "US Customary": [ f"{shell_od_in} in", f"{tube_od_in} in", f"{tube_len_ft} ft", f"{tube_qty}", ], "SI / Metric": [ f"{shell_od_mm} mm", f"{tube_od_mm} mm", f"{tube_len_m} m", f"{tube_qty}", ], "Rounding Rule": [ "Nearest 2 in (50.8 mm)", "Nearest 1/8 in (3.175 mm)", "Nearest whole foot", "Nearest whole number", ], "Confidence": [ "✅ High (R² = 0.90)", "✅ High (R² = 0.977)", "⚠️ Moderate (R² = 0.79, ±15%)", "⚠️ Moderate (R² = 0.82, ±15%)", ], }) st.dataframe(summary, use_container_width=True, hide_index=True) # ============================================================ # FOOTER # ============================================================ st.divider() st.caption( "Model: XGBoost Hybrid v4 | Trained on 1,507 HTRI datasheets | " "For preliminary sizing only — not a substitute for rigorous thermal design." )