| """ |
| 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() |
|
|