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**.")