Spaces:
Sleeping
Sleeping
| 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 | |
| # ============================================================ | |
| 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." | |
| ) |