shrut27's picture
Upload app.py with huggingface_hub
85c7668 verified
Raw
History Blame Contribute Delete
16.7 kB
"""
Closed-loop PFAS-SBEAD Optimization Pipeline — Streamlit Application.
AI-driven optimization for PFAS degradation using Sidestream Bioelectrochemical
Anaerobic Digestion (SBEAD) reactor system.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from utils.calculations import (
FEATURE_COLUMNS,
AI_SCORE_WEIGHTS,
bayesian_next_recommendation,
compute_shap_importance,
sensitivity_analysis,
train_degradation_model,
train_fluoride_model,
train_instability_detector,
train_short_chain_classifier,
)
from utils.data_generator import generate_mass_balance_data, generate_sbead_dataset
from utils.visualizations import (
ai_score_distribution,
degradation_heatmap,
degradation_scatter,
dual_axis_performance,
energy_vs_degradation,
feature_importance_bar,
mass_balance_sunburst,
optimization_pareto,
sensitivity_bar,
stability_radar,
)
st.set_page_config(
page_title="PFAS-SBEAD AI Pipeline",
page_icon="⚗️",
layout="wide",
initial_sidebar_state="expanded",
)
DATA_PATH = Path(__file__).parent / "data"
@st.cache_data
def load_data() -> tuple[pd.DataFrame, pd.DataFrame]:
exp_path = DATA_PATH / "sbead_experiments.csv"
mb_path = DATA_PATH / "mass_balance.csv"
if exp_path.exists() and mb_path.exists():
df = pd.read_csv(exp_path)
mb = pd.read_csv(mb_path)
else:
df = generate_sbead_dataset(120)
mb = generate_mass_balance_data(df)
return df, mb
@st.cache_resource
def train_models(df: pd.DataFrame):
deg_model, deg_r2 = train_degradation_model(df)
flu_model, flu_r2 = train_fluoride_model(df)
sc_model, sc_acc = train_short_chain_classifier(df)
iso_model = train_instability_detector(df)
return {
"degradation": (deg_model, deg_r2),
"fluoride": (flu_model, flu_r2),
"short_chain": (sc_model, sc_acc),
"instability": iso_model,
}
def render_sidebar(df: pd.DataFrame) -> dict:
"""Sidebar filters and controls."""
st.sidebar.title("⚗️ PFAS-SBEAD Pipeline")
st.sidebar.markdown("---")
st.sidebar.subheader("Experiment Filters")
olr_range = st.sidebar.slider(
"OLR (kg/m³/d)", 1.0, 6.0, (1.0, 6.0), 0.1
)
voltage_range = st.sidebar.slider(
"Voltage (V)", 0.2, 1.2, (0.2, 1.2), 0.05
)
hrt_range = st.sidebar.slider(
"HRT (days)", 10.0, 30.0, (10.0, 30.0), 1.0
)
ph_range = st.sidebar.slider(
"pH", 6.5, 8.0, (6.5, 8.0), 0.1
)
st.sidebar.markdown("---")
st.sidebar.subheader("AI Objective Weights")
w_deg = st.sidebar.slider("PFAS degradation weight", 0.0, 1.0, 0.40, 0.05)
w_flu = st.sidebar.slider("Fluoride release weight", 0.0, 1.0, 0.30, 0.05)
w_sc = st.sidebar.slider("Short-chain penalty", 0.0, 0.5, 0.15, 0.05)
w_en = st.sidebar.slider("Energy penalty", 0.0, 0.5, 0.10, 0.05)
w_inst = st.sidebar.slider("Instability penalty", 0.0, 0.5, 0.05, 0.05)
mask = (
(df["OLR_kg_m3_d"] >= olr_range[0])
& (df["OLR_kg_m3_d"] <= olr_range[1])
& (df["voltage_V"] >= voltage_range[0])
& (df["voltage_V"] <= voltage_range[1])
& (df["HRT_days"] >= hrt_range[0])
& (df["HRT_days"] <= hrt_range[1])
& (df["pH"] >= ph_range[0])
& (df["pH"] <= ph_range[1])
)
return {
"mask": mask,
"weights": {"degradation": w_deg, "fluoride": w_flu, "short_chain": w_sc, "energy": w_en, "instability": w_inst},
}
def render_kpi_header(df: pd.DataFrame) -> None:
"""Top KPI metric cards."""
col1, col2, col3, col4, col5 = st.columns(5)
col1.metric(
"Avg Degradation",
f"{df['PFAS_degradation_pct'].mean():.1f}%",
delta=f"max {df['PFAS_degradation_pct'].max():.1f}%",
)
col2.metric(
"Avg Fluoride Release",
f"{df['fluoride_release_mg_L'].mean():.1f} mg/L",
)
col3.metric(
"Avg AI Score",
f"{df['AI_score'].mean():.3f}",
delta=f"best {df['AI_score'].max():.3f}",
)
col4.metric(
"Unstable Runs",
f"{df['instability_flag'].sum()}/{len(df)}",
)
col5.metric(
"Avg Energy",
f"{df['energy_input_kWh_d'].mean():.3f} kWh/d",
)
def render_overview_tab(df: pd.DataFrame, mb_df: pd.DataFrame) -> None:
"""Overview tab with main charts."""
st.subheader("Experiment Overview")
c1, c2 = st.columns(2)
with c1:
st.plotly_chart(degradation_scatter(df), use_container_width=True)
with c2:
st.plotly_chart(dual_axis_performance(df), use_container_width=True)
c3, c4 = st.columns(2)
with c3:
st.plotly_chart(ai_score_distribution(df), use_container_width=True, key="overview_ai_dist")
with c4:
st.plotly_chart(mass_balance_sunburst(mb_df), use_container_width=True, key="overview_mass_balance")
st.subheader("PFAS Degradation Heatmap")
st.plotly_chart(degradation_heatmap(df), use_container_width=True)
def render_ai_models_tab(df: pd.DataFrame, models: dict) -> None:
"""AI Models tab showing training results and predictions."""
st.subheader("AI Model Performance")
deg_model, deg_r2 = models["degradation"]
flu_model, flu_r2 = models["fluoride"]
sc_model, sc_acc = models["short_chain"]
mc1, mc2, mc3 = st.columns(3)
mc1.metric("XGBoost Degradation (R²)", f"{deg_r2:.3f}")
mc2.metric("RF Fluoride Release (R²)", f"{flu_r2:.3f}")
mc3.metric("Short-Chain Classifier (Acc)", f"{sc_acc:.3f}")
st.markdown("---")
st.subheader("Feature Importance (PFAS Degradation Model)")
imp_df = compute_shap_importance(deg_model, df)
st.plotly_chart(feature_importance_bar(imp_df), use_container_width=True)
st.subheader("Sensitivity Analysis")
sens_df = sensitivity_analysis(df)
st.plotly_chart(sensitivity_bar(sens_df), use_container_width=True)
st.subheader("Instability Detection")
iso_model = models["instability"]
stability_cols = ["pH_drop", "VFA_accumulation_mg_L", "ORP_drift_mV", "current_instability_index"]
preds = iso_model.predict(df[stability_cols])
n_anomalies = int((preds == -1).sum())
st.info(f"Isolation Forest detected **{n_anomalies}** potentially unstable experiments out of {len(df)}.")
st.plotly_chart(stability_radar(df), use_container_width=True, key="models_stability_radar")
def render_optimization_tab(df: pd.DataFrame) -> None:
"""Optimization and Bayesian recommendation tab."""
st.subheader("Closed-Loop Optimization")
st.markdown(
"The pipeline uses Bayesian-inspired optimization to recommend the next best "
"reactor condition, balancing exploitation (high-performing conditions) with "
"exploration (uncertain regions)."
)
c1, c2 = st.columns(2)
with c1:
st.plotly_chart(optimization_pareto(df), use_container_width=True)
with c2:
st.plotly_chart(energy_vs_degradation(df), use_container_width=True)
st.markdown("---")
st.subheader("Next Experiment Recommendation")
rec = bayesian_next_recommendation(df)
rc1, rc2, rc3, rc4 = st.columns(4)
rc1.metric("Predicted Degradation", f"{rec['predicted_degradation_pct']:.1f}%")
rc2.metric("Predicted Fluoride", f"{rec['predicted_fluoride_release']:.1f} mg/L")
rc3.metric("Expected AI Score", f"{rec['expected_ai_score']:.3f}")
rc4.metric("Confidence", f"{rec['confidence']:.0%}")
st.markdown("##### Recommended Operating Conditions")
rec_display = {k: v for k, v in rec.items() if k in FEATURE_COLUMNS}
rec_df = pd.DataFrame([rec_display])
st.dataframe(rec_df.round(3), use_container_width=True, hide_index=True)
with st.expander("Optimization Strategy"):
st.markdown("""
**AI Score Function:**
`AI Score = 0.40 × PFAS degradation + 0.30 × fluoride release - 0.15 × short-chain risk - 0.10 × energy input - 0.05 × instability`
**Objective:** Maximize PFAS degradation (>40%) and fluoride release while minimizing
short-chain PFAS accumulation, energy input, and reactor instability.
**Method:** The optimizer identifies top-performing experiments, computes mean conditions,
and recommends the next trial from the region of highest expected improvement.
""")
def render_mass_balance_tab(df: pd.DataFrame, mb_df: pd.DataFrame) -> None:
"""Mass balance analysis tab."""
st.subheader("PFAS Mass Balance")
st.markdown(
"PFAS degradation must be distinguished from adsorption. The mass balance is:\n\n"
"`Initial PFAS = Remaining in water + Adsorbed on sludge + Adsorbed on electrode "
"+ Short-chain products + Mineralized PFAS`"
)
st.plotly_chart(mass_balance_sunburst(mb_df), use_container_width=True, key="tab_mass_balance_sunburst")
st.subheader("Mass Balance Details")
st.dataframe(
mb_df.round(2),
use_container_width=True,
hide_index=True,
height=400,
)
st.subheader("Mass Balance Closure")
fig_closure = px.histogram(
mb_df,
x="mass_balance_closure_pct",
nbins=20,
title="Mass Balance Closure Distribution",
labels={"mass_balance_closure_pct": "Closure (%)"},
template="plotly_white",
)
st.plotly_chart(fig_closure, use_container_width=True)
def render_reactor_stability_tab(df: pd.DataFrame) -> None:
"""Reactor stability monitoring tab."""
st.subheader("Reactor Stability Monitoring")
c1, c2 = st.columns(2)
with c1:
fig_ph = px.scatter(
df,
x="experiment_id",
y="pH_drop",
color="instability_flag",
title="pH Drop Across Experiments",
labels={"pH_drop": "pH Drop", "experiment_id": "Experiment"},
color_discrete_map={0: "green", 1: "red"},
template="plotly_white",
)
fig_ph.add_hline(y=0.8, line_dash="dash", line_color="red",
annotation_text="Instability threshold")
st.plotly_chart(fig_ph, use_container_width=True)
with c2:
fig_vfa = px.scatter(
df,
x="experiment_id",
y="VFA_accumulation_mg_L",
color="instability_flag",
title="VFA Accumulation",
labels={"VFA_accumulation_mg_L": "VFA (mg/L)", "experiment_id": "Experiment"},
color_discrete_map={0: "green", 1: "red"},
template="plotly_white",
)
fig_vfa.add_hline(y=1200, line_dash="dash", line_color="red",
annotation_text="High VFA threshold")
st.plotly_chart(fig_vfa, use_container_width=True)
st.plotly_chart(stability_radar(df), use_container_width=True, key="stability_tab_radar")
st.subheader("Instability Detection Summary")
stable = df[df["instability_flag"] == 0]
unstable = df[df["instability_flag"] == 1]
sc1, sc2 = st.columns(2)
with sc1:
st.success(f"**Stable experiments:** {len(stable)}")
if not stable.empty:
st.dataframe(
stable[["experiment_id", "pH_drop", "VFA_accumulation_mg_L",
"current_instability_index", "AI_score"]].describe().round(3),
use_container_width=True,
)
with sc2:
st.error(f"**Unstable experiments:** {len(unstable)}")
if not unstable.empty:
st.dataframe(
unstable[["experiment_id", "pH_drop", "VFA_accumulation_mg_L",
"current_instability_index", "AI_score"]].describe().round(3),
use_container_width=True,
)
def render_prediction_tab(df: pd.DataFrame, models: dict) -> None:
"""Interactive prediction tab for new experiments."""
st.subheader("Predict New Experiment")
st.markdown("Adjust reactor parameters below to predict PFAS degradation performance.")
with st.form("prediction_form"):
pc1, pc2, pc3, pc4 = st.columns(4)
with pc1:
olr = st.number_input("OLR (kg/m³/d)", 1.0, 6.0, 3.5, 0.1)
hrt = st.number_input("HRT (days)", 10.0, 30.0, 20.0, 1.0)
ph = st.number_input("pH", 6.5, 8.0, 7.2, 0.1)
temp = st.number_input("Temperature (°C)", 30.0, 42.0, 37.0, 0.5)
with pc2:
cod = st.number_input("COD (mg/L)", 2000.0, 8000.0, 5000.0, 100.0)
vfa = st.number_input("VFA (mg/L)", 100.0, 1500.0, 500.0, 50.0)
alk = st.number_input("Alkalinity (mg CaCO₃/L)", 1500.0, 5000.0, 3000.0, 100.0)
with pc3:
volt = st.number_input("Voltage (V)", 0.2, 1.2, 0.7, 0.05)
curr = st.number_input("Current (A)", 0.1, 3.6, 1.5, 0.1)
cd = st.number_input("Current Density (A/m²)", 0.1, 5.0, 1.5, 0.1)
cond = st.number_input("Conductivity (mS/cm)", 1.0, 8.0, 4.0, 0.5)
with pc4:
ea = st.number_input("Electrode Area (m²)", 0.5, 2.0, 1.0, 0.1)
es = st.number_input("Electrode Spacing (cm)", 1.0, 5.0, 3.0, 0.5)
init_pfas = st.number_input("Initial PFAS (µg/L)", 50.0, 500.0, 200.0, 10.0)
submitted = st.form_submit_button("Predict", type="primary", use_container_width=True)
if submitted:
features = np.array([[olr, hrt, ph, temp, cod, vfa, alk, volt, curr, cd, cond, ea, es, init_pfas]])
deg_model, _ = models["degradation"]
flu_model, _ = models["fluoride"]
sc_model, _ = models["short_chain"]
pred_deg = float(deg_model.predict(features)[0])
pred_flu = float(flu_model.predict(features)[0])
pred_sc_risk = int(sc_model.predict(features)[0])
st.markdown("---")
st.subheader("Prediction Results")
pr1, pr2, pr3 = st.columns(3)
pr1.metric("Predicted PFAS Degradation", f"{pred_deg:.1f}%")
pr2.metric("Predicted Fluoride Release", f"{pred_flu:.1f} mg/L")
pr3.metric(
"Short-Chain Risk",
"HIGH" if pred_sc_risk == 1 else "LOW",
delta="⚠️" if pred_sc_risk == 1 else "✓",
delta_color="inverse" if pred_sc_risk == 1 else "normal",
)
energy = volt * curr * 24 / 1000
deg_norm = pred_deg / 65.0
flu_norm = pred_flu / (df["fluoride_release_mg_L"].max() or 1)
en_norm = energy / (df["energy_input_kWh_d"].max() or 1)
ai_score = np.clip(
0.40 * deg_norm + 0.30 * flu_norm - 0.15 * pred_sc_risk - 0.10 * en_norm - 0.05 * 0.1,
0, 1
)
st.metric("Predicted AI Score", f"{ai_score:.3f}")
def render_data_tab(df: pd.DataFrame) -> None:
"""Raw data exploration tab."""
st.subheader("Experiment Data Explorer")
st.dataframe(df, use_container_width=True, hide_index=True, height=500)
csv = df.to_csv(index=False).encode("utf-8")
st.download_button(
"Download Data (CSV)",
csv,
"pfas_sbead_experiments.csv",
"text/csv",
use_container_width=True,
)
def main() -> None:
df, mb_df = load_data()
sidebar_config = render_sidebar(df)
filtered_df = df[sidebar_config["mask"]].reset_index(drop=True)
filtered_mb = mb_df[mb_df["experiment_id"].isin(filtered_df["experiment_id"])].reset_index(drop=True)
st.title("Closed-Loop PFAS-SBEAD Optimization Pipeline")
st.caption(
"AI-driven optimization for PFAS degradation using Sidestream Bioelectrochemical "
"Anaerobic Digestion (SBEAD). Maximizes degradation and fluoride release while "
"minimizing short-chain accumulation, energy input, and reactor instability."
)
render_kpi_header(filtered_df)
st.markdown("---")
models = train_models(df)
tab_overview, tab_models, tab_optim, tab_mass, tab_stability, tab_predict, tab_data = st.tabs([
"📈 Overview",
"🤖 AI Models",
"🎯 Optimization",
"⚖️ Mass Balance",
"🛡️ Stability",
"🔮 Predict",
"📊 Data",
])
with tab_overview:
render_overview_tab(filtered_df, filtered_mb)
with tab_models:
render_ai_models_tab(filtered_df, models)
with tab_optim:
render_optimization_tab(filtered_df)
with tab_mass:
render_mass_balance_tab(filtered_df, filtered_mb)
with tab_stability:
render_reactor_stability_tab(filtered_df)
with tab_predict:
render_prediction_tab(filtered_df, models)
with tab_data:
render_data_tab(filtered_df)
if __name__ == "__main__":
main()