""" Streamlit dashboard: Pharma Demand Forecasting & Replenishment """ import streamlit as st import pandas as pd import numpy as np import plotly.express as px import plotly.graph_objects as go from plotly.subplots import make_subplots import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from simulation_engine import run_arena, list_demo_skus st.set_page_config(page_title="Pharma Demand Forecasting", layout="wide") # ------------------------------------------------------------------ # Load data # ------------------------------------------------------------------ DATA_DIR = os.path.join(os.path.dirname(__file__), "data") @st.cache_data def load_all(): products = pd.read_csv(os.path.join(DATA_DIR, "products.csv")) demand = pd.read_csv(os.path.join(DATA_DIR, "demand.csv")) inventory = pd.read_csv(os.path.join(DATA_DIR, "inventory.csv")) replenishment = pd.read_csv(os.path.join(DATA_DIR, "replenishment.csv")) vbp = pd.read_csv(os.path.join(DATA_DIR, "vbp_impact.csv")) demand["month"] = pd.to_datetime(demand["month"]) inventory["month"] = pd.to_datetime(inventory["month"]) vbp["month"] = pd.to_datetime(vbp["month"]) return products, demand, inventory, replenishment, vbp try: products, demand, inventory, replenishment, vbp = load_all() except Exception: st.error("Data not found. Run `python data/generate_data_sdv.py` (or generate_data.py) first.") st.stop() @st.cache_data def run_arena_cached(sku_id, horizon_days, vbp_shock_day, seed, inject_vbp): return run_arena(sku_id=sku_id, horizon_days=horizon_days, vbp_shock_day=vbp_shock_day, seed=seed, inject_vbp=inject_vbp) # ------------------------------------------------------------------ # Sidebar # ------------------------------------------------------------------ st.sidebar.title("Filters") therapy_filter = st.sidebar.multiselect( "Therapy Area", options=products["therapy_area"].unique(), default=products["therapy_area"].unique(), ) vbp_only = st.sidebar.checkbox("VBP Products Only", value=False) filtered_prods = products[ products["therapy_area"].isin(therapy_filter) & ((not vbp_only) | products["vbp_flag"]) ] selected_skus = filtered_prods["sku_id"].tolist() # ------------------------------------------------------------------ # KPI Cards # ------------------------------------------------------------------ st.title("💊 Pharma Demand Forecasting & Replenishment") col1, col2, col3, col4 = st.columns(4) total_skus = len(filtered_prods) total_inv_value = inventory[inventory["sku_id"].isin(selected_skus)]["inventory_value_cny"].iloc[-len(selected_skus):].sum() stockout_count = inventory[inventory["sku_id"].isin(selected_skus)]["stockout_flag"].sum() high_priority_orders = replenishment[ (replenishment["sku_id"].isin(selected_skus)) & (replenishment["priority"] == "High") ]["order_value_cny"].sum() col1.metric("Products", f"{total_skus}") col2.metric("Inventory Value (CNY)", f"¥{total_inv_value:,.0f}") col3.metric("Stock-out Events", f"{int(stockout_count)}") col4.metric("High-Priority Orders (CNY)", f"¥{high_priority_orders:,.0f}") # ------------------------------------------------------------------ # Tabs # ------------------------------------------------------------------ tab1, tab2, tab3, tab4, tab5 = st.tabs([ "📈 Demand vs Forecast", "📦 Inventory & Stock-outs", "🔄 Replenishment", "💰 VBP Impact", "🏟️ Simulation Arena", ]) # --- TAB 1: Demand vs Forecast --- with tab1: st.subheader("Historical Demand vs. Forecast") d_filt = demand[demand["sku_id"].isin(selected_skus)] d_agg = d_filt.groupby("month").agg({"actual_demand": "sum", "forecast_demand": "sum"}).reset_index() fig1 = go.Figure() fig1.add_trace(go.Scatter(x=d_agg["month"], y=d_agg["actual_demand"], mode="lines+markers", name="Actual Demand")) fig1.add_trace(go.Scatter(x=d_agg["month"], y=d_agg["forecast_demand"], mode="lines+markers", name="Forecast", line=dict(dash="dash"))) fig1.update_layout(height=450, xaxis_title="Month", yaxis_title="Units", legend=dict(orientation="h", yanchor="bottom", y=1.02)) st.plotly_chart(fig1, use_container_width=True) st.subheader("Forecast Accuracy by SKU (last 6 months)") acc = [] for sku in selected_skus: sub = d_filt[d_filt["sku_id"] == sku].sort_values("month").tail(6) if len(sub) == 0: continue mape = np.mean(np.abs((sub["actual_demand"] - sub["forecast_demand"]) / sub["actual_demand"].replace(0, np.nan))) * 100 acc.append({"SKU": sku, "MAPE": mape}) df_acc = pd.DataFrame(acc) if not df_acc.empty: df_acc = df_acc.sort_values("MAPE", ascending=False) fig1b = px.bar(df_acc, x="SKU", y="MAPE", color="MAPE", color_continuous_scale="RdYlGn_r") fig1b.update_layout(height=350) st.plotly_chart(fig1b, use_container_width=True) # --- TAB 2: Inventory --- with tab2: st.subheader("Inventory Levels Over Time") i_filt = inventory[inventory["sku_id"].isin(selected_skus)] i_agg = i_filt.groupby("month").agg({"ending_inventory": "sum", "inventory_value_cny": "sum"}).reset_index() fig2 = make_subplots(specs=[[{"secondary_y": True}]]) fig2.add_trace(go.Bar(x=i_agg["month"], y=i_agg["ending_inventory"], name="Units", marker_color="steelblue"), secondary_y=False) fig2.add_trace(go.Scatter(x=i_agg["month"], y=i_agg["inventory_value_cny"], name="Value (CNY)", mode="lines+markers", line=dict(color="darkorange")), secondary_y=True) fig2.update_layout(height=450, legend=dict(orientation="h", yanchor="bottom", y=1.02)) fig2.update_yaxes(title_text="Units", secondary_y=False) fig2.update_yaxes(title_text="CNY", secondary_y=True) st.plotly_chart(fig2, use_container_width=True) st.subheader("Stock-out Events") stockouts = i_filt[i_filt["stockout_flag"]].groupby("sku_id").size().reset_index(name="stockout_count") if not stockouts.empty: fig2b = px.bar(stockouts, x="sku_id", y="stockout_count", color="stockout_count", color_continuous_scale="Reds") fig2b.update_layout(height=350) st.plotly_chart(fig2b, use_container_width=True) else: st.info("No stock-outs in selected range — great job!") # --- TAB 3: Replenishment --- with tab3: st.subheader("Current Replenishment Recommendations") r_filt = replenishment[replenishment["sku_id"].isin(selected_skus)].merge( products[["sku_id", "product_name", "vbp_flag"]], on="sku_id" ) r_filt = r_filt[["sku_id", "product_name", "vbp_flag", "current_inventory", "avg_monthly_demand", "safety_stock", "reorder_point", "suggested_order_qty", "order_value_cny", "priority"]] # Color-coded priority def color_priority(val): if val == "High": return "background-color: #ffcccc" elif val == "Medium": return "background-color: #ffffcc" return "background-color: #ccffcc" st.dataframe( r_filt.style.map(color_priority, subset=["priority"]), height=400, ) st.subheader("Suggested Order Value by Priority") fig3 = px.pie(r_filt, names="priority", values="order_value_cny", hole=0.4, color="priority", color_discrete_map={"High": "#ff6b6b", "Medium": "#feca57", "Low": "#1dd1a1"}) fig3.update_layout(height=400) st.plotly_chart(fig3, use_container_width=True) # --- TAB 4: VBP Impact --- with tab4: st.subheader("Volume-Based Procurement (VBP) Price & Volume Impact") v_filt = vbp[vbp["sku_id"].isin(selected_skus)].merge(products[["sku_id", "product_name"]], on="sku_id") if not v_filt.empty: sku_choice = st.selectbox("Select VBP Product", v_filt["sku_id"].unique()) v_sub = v_filt[v_filt["sku_id"] == sku_choice] fig4 = make_subplots(specs=[[{"secondary_y": True}]]) fig4.add_trace(go.Scatter(x=v_sub["month"], y=v_sub["pre_vbp_price"], name="Pre-VBP Price", mode="lines", line=dict(dash="dot")), secondary_y=False) fig4.add_trace(go.Scatter(x=v_sub["month"], y=v_sub["post_vbp_price"], name="Post-VBP Price", mode="lines+markers"), secondary_y=False) fig4.add_trace(go.Bar(x=v_sub["month"], y=v_sub["volume_uplift_pct"]*100, name="Volume Uplift %", marker_color="green", opacity=0.5), secondary_y=True) fig4.update_layout(height=450, title=f"{v_sub['product_name'].iloc[0]} ({sku_choice})", legend=dict(orientation="h", yanchor="bottom", y=1.02)) fig4.update_yaxes(title_text="Price (CNY)", secondary_y=False) fig4.update_yaxes(title_text="Volume Uplift %", secondary_y=True) st.plotly_chart(fig4, use_container_width=True) else: st.info("No VBP products match current filter.") # --- TAB 5: Simulation Arena --- with tab5: st.subheader("🏟️ Simulation Arena — Naïve vs Adaptive Replenishment") st.caption( "Two universes run the SAME demand from the same seed; only the " "replenishment policy differs. **Universe A** uses a naïve reorder point " "(fixed safety stock, 7-day average, blind to the VBP shock). " "**Universe B** reviews periodically, sizes safety stock from demand " "volatility, and anticipates the VBP demand spillover." ) demo = list_demo_skus() demo["label"] = (demo["product_name"] + " · " + demo["demand_class"] + demo["vbp_flag"].map({True: " · VBP", False: ""})) c1, c2, c3 = st.columns([2, 1, 1]) with c1: idx = st.selectbox("SKU", options=list(demo.index), format_func=lambda i: demo.loc[i, "label"]) arena_sku = demo.loc[idx, "sku_id"] with c2: horizon = st.slider("Horizon (days)", 90, 365, 180, step=15) with c3: seed = st.number_input("Seed", min_value=0, max_value=9999, value=42, step=1) c4, c5 = st.columns([1, 2]) with c4: inject_vbp = st.checkbox("Inject VBP shock", value=True) with c5: vbp_day = st.slider("VBP shock day", 10, int(horizon) - 10, min(90, int(horizon) - 10), step=5, disabled=not inject_vbp) res = run_arena_cached(arena_sku, int(horizon), int(vbp_day), int(seed), bool(inject_vbp)) A, B, delta = res["A"]["kpis"], res["B"]["kpis"], res["delta"] st.markdown(f"#### Outcome ({int(horizon)}-day P&L)") k1, k2, k3, k4 = st.columns(4) k1.metric("Gross margin Δ (B−A)", f"¥{delta['gross_margin']:,.0f}", help=f"A ¥{A['gross_margin']:,.0f} · B ¥{B['gross_margin']:,.0f}") k2.metric("Service level Δ", f"{delta['service_level']*100:+.1f} pp", help=f"A {A['service_level']*100:.1f}% · B {B['service_level']*100:.1f}%") k3.metric("Stock-out days Δ", f"{delta['stockout_days']:+d}", delta=f"{delta['stockout_days']:+d}", delta_color="inverse", help=f"A {A['stockout_days']} · B {B['stockout_days']}") k4.metric("Avg on-hand Δ", f"{delta['avg_on_hand']:+,.0f}", help=f"A {A['avg_on_hand']:,.0f} · B {B['avg_on_hand']:,.0f}") days = list(range(res["config"]["horizon_days"])) figA = go.Figure() figA.add_trace(go.Bar(x=days, y=res["demand"], name="Daily demand", marker_color="lightgray")) figA.add_trace(go.Scatter(x=days, y=res["A"]["series"]["on_hand"], name="A on-hand (naïve)", line=dict(color="#ff6b6b"))) figA.add_trace(go.Scatter(x=days, y=res["B"]["series"]["on_hand"], name="B on-hand (adaptive)", line=dict(color="#1dd1a1"))) if inject_vbp and res["sku"].vbp_flag: figA.add_vline(x=int(vbp_day), line_dash="dot", line_color="purple", annotation_text="VBP shock") figA.update_layout(height=380, title="Inventory on hand vs daily demand", xaxis_title="Day", yaxis_title="Units", legend=dict(orientation="h", y=1.02)) st.plotly_chart(figA, use_container_width=True) cc1, cc2 = st.columns(2) with cc1: figB = go.Figure() figB.add_trace(go.Scatter(x=days, y=res["A"]["series"]["cum_cash"], name="A", line=dict(color="#ff6b6b"))) figB.add_trace(go.Scatter(x=days, y=res["B"]["series"]["cum_cash"], name="B", line=dict(color="#1dd1a1"))) figB.update_layout(height=320, title="Cumulative net margin (¥)", xaxis_title="Day", legend=dict(orientation="h", y=1.02)) st.plotly_chart(figB, use_container_width=True) with cc2: figC = go.Figure() figC.add_trace(go.Bar(x=days, y=res["A"]["series"]["unmet"], name="A unmet", marker_color="#ff6b6b")) figC.add_trace(go.Bar(x=days, y=res["B"]["series"]["unmet"], name="B unmet", marker_color="#1dd1a1")) figC.update_layout(height=320, title="Daily unmet demand (stock-outs)", barmode="overlay", xaxis_title="Day", legend=dict(orientation="h", y=1.02)) figC.update_traces(opacity=0.7) st.plotly_chart(figC, use_container_width=True) with st.expander(f"📋 Event log ({len(res['events'])} events)"): if res["events"]: ev = pd.DataFrame(res["events"], columns=["Day", "Event", "Detail"]) st.dataframe(ev, height=300) else: st.info("No notable events for this configuration.") st.markdown("---") st.caption("Synthetic demo data • Built for Pharma Demand Forecasting case")