Umar4321's picture
Update app.py
5824e06 verified
Raw
History Blame Contribute Delete
14.3 kB
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."
)