import json import numpy as np import pandas as pd import plotly.graph_objects as go import plotly.express as px import streamlit as st import os st.set_page_config(page_title="Hybrid Attendance Monte Carlo", layout="wide") st.title("Hybrid Office Attendance: Monte Carlo PoC") here=os.path.dirname(os.path.abspath(__file__)) preset_path = os.path.join(here,"presets.json") def load_presets(): with open(preset_path, "r") as f: return json.load(f) presets=load_presets() preset_names = list(presets.keys()) with st.sidebar: st.header("Scenario Presets") chosen = st.selectbox("Pick a preset", preset_names, index=0) seed = st.number_input("Random seed", 0, 10000, 42, step=1) st.caption("Start with a preset, then tweak below as needed.") # ---- Inputs (main panel) ---- cfg = presets[chosen].copy() colA, colB, colC, colD = st.columns(4) n_employees = colA.number_input("Employees", 10, 20000, int(cfg.get("n_employees", 300)), step=10) n_days = colB.number_input("Sim days", 5, 365, int(cfg.get("n_days", 60)), step=5) n_runs = colC.number_input("Monte Carlo runs", 100, 50000, int(cfg.get("n_runs", 2000)), step=100) capacity = colD.number_input("Capacity (seats/desk count)", 0, 100000, int(cfg.get("capacity", 220)), step=10) st.subheader("Day-of-Week Base Patterns") with st.expander("Weekly attendance patterns", expanded=True): st.write("Set base attendance probability for each day of the week:") dow_cols = st.columns(7) days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] day_abbrev = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] # Default pattern: Thu highest, Tue second, Fri lowest default_dow_rates = cfg.get("dow_base_rates", [0.35, 0.55, 0.45, 0.65, 0.25, 0.10, 0.05]) dow_base_rates = [] for i, (day, abbr) in enumerate(zip(days, day_abbrev)): rate = dow_cols[i].slider(f"{abbr}", 0.0, 1.0, float(default_dow_rates[i]), 0.05, key=f"dow_{i}") dow_base_rates.append(rate) st.subheader("Model Parameters") with st.expander("Simulation settings", expanded=True): c1, c2, c3 = st.columns(3) noise_sd = c1.slider("Day-to-day volatility (σ)", 0.0, 0.5, float(cfg.get("noise_sd", 0.15)), 0.01) team_corr = c2.slider("Team synchronicity (0=independent, 1=lockstep)", 0.0, 0.95, float(cfg.get("team_corr", 0.25)), 0.05) dow_strength = c3.slider("Day-of-week pattern strength", 0.0, 1.0, float(cfg.get("dow_strength", 0.8)), 0.05) st.caption("Higher volatility = more day-to-day variation. Lower day-of-week strength = more random attendance patterns.") st.subheader("Employee Groups") with st.expander("Define employee attendance groups", expanded=True): st.write("Create different employee groups with distinct attendance patterns:") # Default groups default_groups = cfg.get("employee_groups", { "Regular (3-4 days/week)": {"share": 0.4, "target_days": 3.5}, "Light (1-2 days/week)": {"share": 0.35, "target_days": 1.5}, "Heavy (4-5 days/week)": {"share": 0.15, "target_days": 4.2}, "Rare (< 1 day/week)": {"share": 0.1, "target_days": 0.4} }) employee_groups = {} group_names = list(default_groups.keys()) # Group configuration group_cols = st.columns(len(group_names)) for i, group_name in enumerate(group_names): with group_cols[i]: st.write(f"**{group_name}**") share = st.slider("Share of employees", 0.0, 1.0, float(default_groups[group_name]["share"]), 0.05, key=f"share_{i}") target_days = st.slider("Target days/week", 0.0, 5.0, float(default_groups[group_name]["target_days"]), 0.1, key=f"target_{i}") employee_groups[group_name] = {"share": share, "target_days": target_days} # Normalize shares total_share = sum(g["share"] for g in employee_groups.values()) if total_share > 0: for group_name in employee_groups: employee_groups[group_name]["share"] /= total_share st.subheader("Advanced: Day-of-Week Modifiers by Group") with st.expander("Customize day-of-week patterns for each employee group", expanded=False): st.write("**Day-of-Week Modifiers by Group**") st.caption("Multiply base day-of-week rates by these factors for each group") # Day-of-week modifiers by group group_dow_modifiers = {} for group_name in employee_groups.keys(): st.write(f"*{group_name}*") modifier_cols = st.columns(7) modifiers = [] default_mods = cfg.get("group_dow_modifiers", {}).get(group_name, [1.0] * 7) for j, abbr in enumerate(day_abbrev): mod = modifier_cols[j].slider(f"{abbr}", 0.0, 3.0, float(default_mods[j] if j < len(default_mods) else 1.0), 0.1, key=f"mod_{group_name}_{j}") modifiers.append(mod) group_dow_modifiers[group_name] = modifiers rng = np.random.default_rng(int(seed)) # ---- Build per-employee probabilities from groups ---- # Assign employees to groups group_counts = [] group_assignments = [] for group_name, group_info in employee_groups.items(): count = int(round(group_info["share"] * n_employees)) group_counts.append(count) group_assignments.extend([group_name] * count) # Adjust for rounding errors diff = n_employees - len(group_assignments) if diff > 0: # Add employees to first group first_group = list(employee_groups.keys())[0] group_assignments.extend([first_group] * diff) elif diff < 0: # Remove from end group_assignments = group_assignments[:n_employees] # Shuffle assignments rng.shuffle(group_assignments) # Create per-employee day-of-week probability matrix emp_dow_probs = np.zeros((n_employees, 7)) for i, group_name in enumerate(group_assignments): group_info = employee_groups[group_name] group_modifiers = group_dow_modifiers[group_name] # Calculate group's day-of-week probabilities group_dow_probs = np.array(dow_base_rates) * np.array(group_modifiers) # Scale to match target days per week current_avg = np.mean(group_dow_probs) target_avg = group_info["target_days"] / 7.0 if current_avg > 0: group_dow_probs = group_dow_probs * (target_avg / current_avg) # Clip to valid probability range group_dow_probs = np.clip(group_dow_probs, 0.0, 1.0) # Add some individual variation variation = rng.normal(0, 0.05, 7) # Small random variation per person emp_dow_probs[i] = np.clip(group_dow_probs + variation, 0.0, 1.0) # Create day-of-week pattern for simulation period weekday = np.arange(int(n_days)) % 7 def simulate_run(): # Generate base probabilities for each employee-day combination base_probs = np.zeros((int(n_employees), int(n_days))) # Calculate average probability across all days for each employee (for non-dow component) avg_emp_probs = np.mean(emp_dow_probs, axis=1) for day in range(int(n_days)): dow = weekday[day] # day of week (0=Monday, 6=Sunday) dow_probs = emp_dow_probs[:, dow] # Blend day-of-week pattern with average (controlled by dow_strength) base_probs[:, day] = dow_strength * dow_probs + (1 - dow_strength) * avg_emp_probs # MUCH LARGER noise for visible day-to-day variation # Common shocks affect everyone (weather, company events, etc.) common_shock = rng.normal(0, noise_sd * 2.0, size=int(n_days)) # Individual variation - each person has their own random daily variation indiv_noise = rng.normal(0, noise_sd * 1.5, size=(int(n_employees), int(n_days))) # Apply shocks with much stronger impact shock = team_corr * common_shock + np.sqrt(max(1 - team_corr**2, 0)) * indiv_noise # Final probabilities with noise - allow broader range before clipping final_probs = np.clip(base_probs + shock, 0.0, 1.0) # Generate attendance (Bernoulli draws) draws = rng.uniform(size=(int(n_employees), int(n_days))) < final_probs daily_counts = draws.sum(axis=0) return daily_counts st.divider() run_btn = st.button("Run Simulation", type="primary") if run_btn: daily_matrix = np.vstack([simulate_run() for _ in range(int(n_runs))]) # [runs x days] summary = pd.DataFrame({ "day": np.arange(1, int(n_days) + 1), "p10": np.percentile(daily_matrix, 10, axis=0), "p25": np.percentile(daily_matrix, 25, axis=0), "p50": np.percentile(daily_matrix, 50, axis=0), "p75": np.percentile(daily_matrix, 75, axis=0), "p90": np.percentile(daily_matrix, 90, axis=0), }).astype({"day": int}) # ----- Visualization: percentile fan chart + capacity line ----- fig = go.Figure() # Add uncertainty bands (filled areas) fig.add_trace(go.Scatter( x=list(summary["day"]) + list(summary["day"][::-1]), y=list(summary["p90"]) + list(summary["p10"][::-1]), fill='toself', fillcolor='rgba(0,100,80,0.1)', line=dict(color='rgba(255,255,255,0)'), name='p10-p90 band' )) fig.add_trace(go.Scatter( x=list(summary["day"]) + list(summary["day"][::-1]), y=list(summary["p75"]) + list(summary["p25"][::-1]), fill='toself', fillcolor='rgba(0,100,80,0.2)', line=dict(color='rgba(255,255,255,0)'), name='p25-p75 band' )) # Add percentile lines fig.add_trace(go.Scatter(x=summary["day"], y=summary["p90"], mode="lines", name="p90", line=dict(color='red', width=1))) fig.add_trace(go.Scatter(x=summary["day"], y=summary["p75"], mode="lines", name="p75", line=dict(color='orange', width=1))) fig.add_trace(go.Scatter(x=summary["day"], y=summary["p50"], mode="lines", name="p50 (median)", line=dict(color='blue', width=2))) fig.add_trace(go.Scatter(x=summary["day"], y=summary["p25"], mode="lines", name="p25", line=dict(color='orange', width=1))) fig.add_trace(go.Scatter(x=summary["day"], y=summary["p10"], mode="lines", name="p10", line=dict(color='red', width=1))) if capacity and capacity > 0: fig.add_trace(go.Scatter(x=summary["day"], y=[capacity]*len(summary), mode="lines", name="capacity", line=dict(dash="dash", color="black", width=2))) fig.update_layout( title="Daily In-Office Headcount (percentiles across Monte Carlo runs)", xaxis_title="Day", yaxis_title="Headcount", legend_title="Series", hovermode='x' ) st.plotly_chart(fig, use_container_width=True) # Individual simulation runs visualization st.subheader("Individual Simulation Runs") with st.expander("Sample of individual runs (shows actual variability)", expanded=True): # Sample runs to show n_sample_runs = min(100, n_runs) # Sample evenly spaced runs sample_indices = np.linspace(0, n_runs-1, n_sample_runs, dtype=int) # Create line chart of individual runs fig_runs = go.Figure() # Add individual run lines (lighter, thinner) for i, run_idx in enumerate(sample_indices): if i < 50: # Limit lines for performance fig_runs.add_trace(go.Scatter( x=summary["day"], y=daily_matrix[run_idx], mode="lines", name=f"Run {run_idx+1}", line=dict(width=0.8, color=f'rgba(100,150,200,0.3)'), showlegend=False, hovertemplate=f"Run {run_idx+1}
Day: %{{x}}
Attendance: %{{y}}" )) # Add median line on top fig_runs.add_trace(go.Scatter( x=summary["day"], y=summary["p50"], mode="lines", name="Median (p50)", line=dict(color='blue', width=3) )) # Add capacity line if capacity and capacity > 0: fig_runs.add_trace(go.Scatter( x=summary["day"], y=[capacity]*len(summary), mode="lines", name="Capacity", line=dict(dash="dash", color="black", width=2) )) fig_runs.update_layout( title=f"Individual Monte Carlo Runs (showing {len(sample_indices)} of {n_runs} runs)", xaxis_title="Day", yaxis_title="Daily Attendance", hovermode='x unified' ) st.plotly_chart(fig_runs, use_container_width=True) # Show variability statistics for the sample sample_data = daily_matrix[sample_indices] col1, col2, col3, col4 = st.columns(4) col1.metric("Runs Shown", len(sample_indices)) col2.metric("Avg Daily Range", f"{np.mean(sample_data.max(axis=0) - sample_data.min(axis=0)):.1f}") col3.metric("Max Single Day", f"{sample_data.max()}") col4.metric("Min Single Day", f"{sample_data.min()}") st.caption("Each thin line is one complete simulation run. You can see the actual day-to-day variation!") util = (summary["p50"] / n_employees).mean() over_days_p50 = int((summary["p50"] > capacity).sum()) if capacity else 0 over_days_p90 = int((summary["p90"] > capacity).sum()) if capacity else 0 c1, c2, c3 = st.columns(3) c1.metric("Median utilization (avg across days)", f"{util:.0%}") c2.metric("Days over capacity @ p50", f"{over_days_p50}") c3.metric("Days over capacity @ p90", f"{over_days_p90}") # Show group summary col1, col2 = st.columns(2) with col1: st.write("**Employee Group Summary**") group_summary = [] for group_name, group_info in employee_groups.items(): count = sum(1 for g in group_assignments if g == group_name) avg_prob = np.mean([emp_dow_probs[i] for i, g in enumerate(group_assignments) if g == group_name]) group_summary.append({ "Group": group_name, "Employees": count, "Target Days/Week": f"{group_info['target_days']:.1f}", "Avg Daily Prob": f"{avg_prob:.1%}" }) st.dataframe(pd.DataFrame(group_summary), hide_index=True) with col2: st.write("**Day-of-Week Pattern & Volatility**") # Calculate actual volatility by day of week dow_volatility = [] dow_p50_values = [] for i in range(7): dow_days = summary[weekday == i]["p50"] if len(dow_days) > 1: volatility = dow_days.std() avg_p50 = dow_days.mean() else: volatility = 0 avg_p50 = dow_days.iloc[0] if len(dow_days) == 1 else 0 dow_volatility.append(volatility) dow_p50_values.append(avg_p50) dow_summary = pd.DataFrame({ "Day": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], "Base Rate": [f"{r:.1%}" for r in dow_base_rates], "Avg P50": [f"{v:.0f}" for v in dow_p50_values], "StdDev": [f"{v:.1f}" for v in dow_volatility] }) st.dataframe(dow_summary, hide_index=True) st.caption("StdDev shows day-to-day variation within each day of week") st.dataframe(summary, use_container_width=True, height=400) st.download_button("Download percentile table (CSV)", summary.to_csv(index=False), "attendance_percentiles.csv") # Raw simulation data output st.subheader("Raw Simulation Data") with st.expander("All Monte Carlo runs (full data)", expanded=False): st.write(f"**Complete dataset**: {n_runs:,} simulation runs × {n_days} days = {n_runs * n_days:,} data points") # Convert daily_matrix to DataFrame for better viewing raw_data = pd.DataFrame(daily_matrix.T) # Transpose so days are rows, runs are columns raw_data.index = range(1, n_days + 1) # Day numbers as index raw_data.index.name = "Day" raw_data.columns = [f"Run_{i+1}" for i in range(n_runs)] # Show statistics col1, col2, col3, col4 = st.columns(4) col1.metric("Min Daily Attendance", f"{raw_data.values.min()}") col2.metric("Max Daily Attendance", f"{raw_data.values.max()}") col3.metric("Overall Std Dev", f"{raw_data.values.std():.1f}") col4.metric("Mean Daily Attendance", f"{raw_data.values.mean():.1f}") # Show sample of the data st.write("**Sample of raw data** (first 20 days, first 10 runs):") sample_data = raw_data.iloc[:20, :10] if n_runs >= 10 else raw_data.iloc[:20, :] st.dataframe(sample_data, use_container_width=True) # Full data download csv_data = raw_data.to_csv() st.download_button( "Download ALL simulation data (CSV)", csv_data, f"attendance_raw_data_{n_runs}runs_{n_days}days.csv", help=f"Downloads {n_runs:,} runs × {n_days} days = {len(csv_data):,} characters of raw data" ) # Show variability by day of week in raw data st.write("**Variability by Day of Week** (from raw data):") dow_analysis = [] days_of_week = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] for dow in range(7): dow_mask = (raw_data.index - 1) % 7 == dow # Day of week mask if dow_mask.sum() > 0: dow_data = raw_data[dow_mask].values.flatten() dow_analysis.append({ "Day": days_of_week[dow], "Count": len(dow_data), "Mean": f"{dow_data.mean():.1f}", "Std": f"{dow_data.std():.1f}", "Min": f"{dow_data.min()}", "Max": f"{dow_data.max()}", "Range": f"{dow_data.max() - dow_data.min()}" }) st.dataframe(pd.DataFrame(dow_analysis), hide_index=True) st.caption("This shows the actual variability in your simulation data") st.markdown("### Notes on interpretation") st.markdown( "- The **uncertainty band** between p10 and p90 tells you how spiky or stable demand is.\n" "- Increase **team synchronicity** to model all-hands/meeting cadence—watch for capacity collisions.\n" "- Tune **role base propensities** to reflect lab staff, field staff, design review rituals, etc." ) else: st.info("Pick a preset and tweak parameters, then click **Run Simulation**.")