# Knoebels Queue Dashboard v1.1 - Scaled wait times fix import streamlit as st import pandas as pd import numpy as np import psycopg2 from psycopg2.extras import RealDictCursor import plotly.express as px import plotly.graph_objects as go import os from datetime import datetime, timedelta from dotenv import load_dotenv # Load environment variables from .env if it exists load_dotenv(os.path.join(os.path.dirname(__file__), '.env')) # --- Configuration --- st.set_page_config( page_title="Knoebels Queue Dashboard", page_icon="🎢", layout="wide", ) # --- Database Connection --- def get_db_connection(): try: conn = psycopg2.connect( host=os.environ.get('DB_HOST', '192.168.88.9'), port=os.environ.get('DB_PORT', '5432'), database=os.environ.get('DB_NAME', 'gp0'), user=os.environ.get('DB_USER', 'kuhnobowls'), password=os.environ.get('DB_PASSWORD'), options="-c search_path=knoebels" ) return conn except Exception as e: st.error(f"Database connection failed: {e}") return None @st.cache_data(ttl=300) def fetch_latest_status(): conn = get_db_connection() if not conn: return pd.DataFrame() query = "SELECT * FROM dm_latest_ride_info" df = pd.read_sql(query, conn) conn.close() if not df.empty: # Safely convert and scale wait times to minutes df['queue_time'] = pd.to_numeric(df['queue_time'], errors='coerce') df['queue_time'] = (df['queue_time'] / 60.0).round(0) # Ensure coordinates are numeric df['lattitude'] = pd.to_numeric(df['lattitude'], errors='coerce') df['longitude'] = pd.to_numeric(df['longitude'], errors='coerce') return df @st.cache_data(ttl=600) def fetch_historical_trends(days=2): conn = get_db_connection() if not conn: return pd.DataFrame() query = f""" SELECT time_stamp, name, queue_time, is_operational, category FROM dm_knb_live_data WHERE time_stamp > NOW() - INTERVAL '{days} days' ORDER BY time_stamp ASC """ df = pd.read_sql(query, conn) conn.close() if not df.empty: # Safely convert and scale wait times to minutes df['queue_time'] = pd.to_numeric(df['queue_time'], errors='coerce') df['queue_time'] = (df['queue_time'] / 60.0).round(1) return df @st.cache_data(ttl=3600) def fetch_categories(): conn = get_db_connection() if not conn: return {} query = 'SELECT _id, name FROM "LZ_attractions_io_categories"' with conn.cursor() as cur: cur.execute(query) cats = {row[0]: row[1] for row in cur.fetchall()} conn.close() return cats # --- PCA: "reading the park's mind" ---------------------------------------- # Reproduces the analysis from the blog post (Reading a Theme Park's Mind with # PCA). Standardize the time x ride wait matrix, take the top 3 components, and # read them as: PC1 = overall busyness, PC2 = Kiddieland vs. main midway # (spatial), PC3 = daytime school-group rides vs. the evening crowd. PC_LABELS = { "PC1": "Overall busyness", "PC2": "Kiddieland ↔ main midway", "PC3": "Daytime crowd ↔ evening crowd", "PC4": "Off-peak spread ↔ headliner pull", } PC_BLURB = { "PC1": "How many people are in the park. Every ride loads the same sign — " "when it's packed, everything spikes together.", "PC2": "Which half of the park they're in. A contrast: Kiddieland corner " "(negative) vs. the main midway (positive). It's geography, not the " "park's own category tags.", "PC3": "What time of day it is. Daytime family flat-rides the school/daycare " "groups hit at open (positive) vs. the evening coaster & date-night " "crowd (negative).", "PC4": "The faint fourth axis (~5% of variance — diminishing returns). A " "contrast between a few secondary rides (super round-up, tumbling " "timbers, flying tigers — positive) and the headliners (Phoenix, " "Impulse, Flying Turns — negative). Its score drifts with the " "calendar: positive in quiet spring weekday mornings, negative on " "busy summer/fall weekend evenings when demand piles onto the " "marquee rides. Real, but mostly where the clean signal ends.", } CATEGORY_COLORS = {"Family": "#4C9BE8", "Kiddie": "#E8A33D", "Thrill": "#E0524E"} # --- Busyness tiers ("How busy is it?" tab) -------------------------------- # Four ordinal levels of crowding, learned from the data (k-means on the # park-wide mean wait), low -> high. Names are deliberately playful. TIER_NAMES = ["Walk right on", "Pleasant", "Packed", "What were you thinking?!"] TIER_EMOJI = ["🚶", "😎", "😤", "🥵"] TIER_COLORS = ["#2E8B57", "#E8C53D", "#E8843D", "#E0524E"] # green→gold→orange→red def _context_features(ts): """Calendar/clock features for the forecast model. Fixed column set so training and live inference always line up, regardless of which months or days happen to be present.""" ts = pd.DatetimeIndex(ts) F = pd.DataFrame(index=range(len(ts))) F["hour_sin"] = np.sin(2 * np.pi * ts.hour / 24) F["hour_cos"] = np.cos(2 * np.pi * ts.hour / 24) F["weekend"] = (ts.weekday >= 5).astype(int) for d in range(7): F[f"dow_{d}"] = (ts.weekday == d).astype(int) for mo in range(4, 11): # Knoebels' April–October season F[f"mo_{mo}"] = (ts.month == mo).astype(int) return F @st.cache_data(ttl=21600) # 6h — underlying history grows slowly, PCA is heavy def compute_pca(): """Pull the full wait history, build the standardized time x ride matrix, and return (loadings, scores, explained_variance_ratio, ride_meta). ride_meta carries each ride's category tag (trailing space trimmed) and GPS coordinates, so the same DataFrame drives the behavioral map.""" from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA from sklearn.cluster import KMeans conn = get_db_connection() if not conn: return None waits = pd.read_sql( """ SELECT a.time_stamp, r.ride AS ride_name, a.queue_time AS queue_time_sec FROM knoebels."LZ_attractions_io" a JOIN knoebels.ride_master r ON r.attractions_dot_io_id = a._id WHERE a.queue_time IS NOT NULL """, conn, ) meta = pd.read_sql( """ SELECT r.ride AS ride_name, trim(c.name) AS category, (p.location)[0]::float AS lat, (p.location)[1]::float AS lon FROM knoebels.ride_master r JOIN knoebels."LZ_attractions_io_poi" p ON p._id = r.attractions_dot_io_id LEFT JOIN knoebels."LZ_attractions_io_categories" c ON c._id = p.category """, conn, ).set_index("ride_name") conn.close() if waits.empty: return None waits["time_stamp"] = pd.to_datetime(waits["time_stamp"]) waits["queue_time_min"] = pd.to_numeric(waits["queue_time_sec"], errors="coerce") / 60.0 # long -> (time x ride) matrix: mean wait per 5-min bin, ffill then zero matrix = ( waits.assign(ts=waits["time_stamp"].dt.floor("5min")) .pivot_table(index="ts", columns="ride_name", values="queue_time_min", aggfunc="mean") .sort_index() .ffill() .fillna(0) .drop(columns=["powersurge"], errors="ignore") # ~90% imputed, noise ) scaled = StandardScaler().fit_transform(matrix) # each ride mean 0, var 1 pca = PCA(n_components=4) # PC4 is faint (~5%) — exposed for exploration only sc = pca.fit_transform(scaled) pc_cols = ["PC1", "PC2", "PC3", "PC4"] scores = pd.DataFrame(sc, index=matrix.index, columns=pc_cols) loadings = pd.DataFrame(pca.components_.T, index=matrix.columns, columns=pc_cols) loadings = loadings.join(meta) # Spatial "areas" from GPS alone — k-means on standardized lat/lon, the same # 5 clusters the write-up uses. Names are derived, not hard-coded to a # cluster index, so they survive re-clustering as the history grows: # - Kiddieland = the cluster whose rides lean most negative on PC2. # - the rest are named for the marquee ride that lands in each. located = loadings.dropna(subset=["lat", "lon"]) if len(located) >= 5: km = KMeans(n_clusters=5, n_init=10, random_state=0).fit_predict( StandardScaler().fit_transform(located[["lat", "lon"]])) area = pd.Series(km, index=located.index) kiddie = loadings.loc[area.index].assign(a=area).groupby("a")["PC2"].mean().idxmin() names = {kiddie: "Kiddieland"} anchors = [("phoenix", "The Woodies"), ("impulse", "The Grand Midway"), ("flying_turns", "Flying Turns Grove"), ("giant_wheel", "Giant Wheel Corner")] for ride, label in anchors: if ride in area.index: names.setdefault(area[ride], label) # don't overwrite Kiddieland loadings["area"] = area.map(lambda a: names.get(a, f"Area {a + 1}")) else: loadings["area"] = "Untagged" return { "loadings": loadings, "scores": scores, "evr": pca.explained_variance_ratio_, } @st.cache_resource(ttl=21600) # 6h — heavy; holds fitted sklearn models def compute_busyness_model(): """Learn 4 busyness tiers from the data, then train two classifiers: - **board-reading**: per-ride waits -> tier. Near-perfect by construction (the tier is derived from the mean of those very waits); it's the live gauge and its coefficients name the 'bellwether' rides. - **calendar forecast**: hour/day/month/weekend -> tier. The honest, non-circular model — weak on the everyday tiers but it pins the rare 'zoo' tier, because those days are locked to specific festival weekends. """ from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_predict, GroupKFold, StratifiedKFold from sklearn.metrics import accuracy_score, confusion_matrix conn = get_db_connection() if not conn: return None waits = pd.read_sql( """ SELECT a.time_stamp, r.ride AS ride_name, a.queue_time AS queue_time_sec FROM knoebels."LZ_attractions_io" a JOIN knoebels.ride_master r ON r.attractions_dot_io_id = a._id WHERE a.queue_time IS NOT NULL """, conn, ) conn.close() if waits.empty: return None waits["time_stamp"] = pd.to_datetime(waits["time_stamp"]) waits["queue_time_min"] = pd.to_numeric(waits["queue_time_sec"], errors="coerce") / 60.0 M = ( waits.assign(ts=waits["time_stamp"].dt.floor("5min")) .pivot_table(index="ts", columns="ride_name", values="queue_time_min", aggfunc="mean") .sort_index().ffill().fillna(0) .drop(columns=["powersurge"], errors="ignore") ) M = M[(M.index.hour >= 10) & (M.index.hour <= 22)] # operating hours if len(M) < 500: return None busy = M.mean(axis=1) # busyness scalar = park-wide mean wait per snapshot # --- learn the tiers: k-means on the 1-D busyness scalar, ordered low->high km = KMeans(n_clusters=4, n_init=10, random_state=0).fit(busy.values.reshape(-1, 1)) rank = np.zeros(4, int) rank[np.argsort(km.cluster_centers_.ravel())] = np.arange(4) y = rank[km.labels_] centers = np.sort(km.cluster_centers_.ravel()) bounds = [(centers[i] + centers[i + 1]) / 2 for i in range(3)] # --- board-reading model: per-ride waits -> tier bscaler = StandardScaler().fit(M.values) Xb = bscaler.transform(M.values) board = LogisticRegression(max_iter=2000, class_weight="balanced") yb = cross_val_predict(board, Xb, y, cv=StratifiedKFold(5, shuffle=True, random_state=0)) board.fit(Xb, y) bell = pd.Series(board.coef_[3], index=M.columns).sort_values() # top-tier drivers # --- calendar forecast model: time context -> tier (grouped by date) F = _context_features(M.index) cscaler = StandardScaler().fit(F.values) Xc = cscaler.transform(F.values) cal = LogisticRegression(max_iter=3000, class_weight="balanced") yc = cross_val_predict(cal, Xc, y, cv=GroupKFold(5), groups=pd.DatetimeIndex(M.index).date) cal.fit(Xc, y) cal_cm = confusion_matrix(y, yc) return { "rides": list(M.columns), "centers": centers, "bounds": bounds, "busy_min": float(busy.min()), "busy_max": float(busy.max()), "tier_share": (np.bincount(y, minlength=4) / len(y)), "n": int(len(M)), "board_scaler": bscaler, "board_clf": board, "board_acc": float(accuracy_score(y, yb)), "board_cm": confusion_matrix(y, yb), "bellwether": bell, "cal_scaler": cscaler, "cal_clf": cal, "cal_cols": list(F.columns), "cal_acc": float(accuracy_score(y, yc)), "cal_cm": cal_cm, "cal_within1": float(np.mean(np.abs(y - yc) <= 1)), "baseline": float(np.mean(y == np.bincount(y).argmax())), "zoo_recall": float(cal_cm[3, 3] / cal_cm[3].sum()) if cal_cm[3].sum() else 0.0, } @st.cache_data(ttl=300) # 5 min — the live snapshot def latest_ride_waits(): """Most recent non-null wait (minutes) per ride, plus the reading's time.""" conn = get_db_connection() if not conn: return None df = pd.read_sql( """ SELECT DISTINCT ON (r.ride) r.ride AS ride_name, a.queue_time AS queue_time_sec, a.time_stamp FROM knoebels."LZ_attractions_io" a JOIN knoebels.ride_master r ON r.attractions_dot_io_id = a._id WHERE a.queue_time IS NOT NULL ORDER BY r.ride, a.time_stamp DESC """, conn, ) conn.close() if df.empty: return None waits = pd.to_numeric(df["queue_time_sec"], errors="coerce") / 60.0 return (pd.Series(waits.values, index=df["ride_name"]), pd.to_datetime(df["time_stamp"]).max()) # --- Header --- st.title("🎢 Knoebels Queue Time Dashboard") st.markdown("---") # --- Sidebar Filters --- st.sidebar.header("Dashboard Filters") latest_df = fetch_latest_status() categories = fetch_categories() if not latest_df.empty: all_cats = sorted(latest_df['category'].dropna().unique()) cat_options = {c: categories.get(c, f"Category {c}") for c in all_cats} selected_cats = st.sidebar.multiselect( "Filter by Category", options=all_cats, format_func=lambda x: cat_options.get(x), default=all_cats ) filtered_latest = latest_df[latest_df['category'].isin(selected_cats)] else: st.warning("No data found in the database. Please check your connection and .env file.") st.stop() # --- Tabs --- tab1, tab2, tab3, tab4, tab5 = st.tabs( ["📍 Live Status", "📈 Historical Trends", "🔍 Ride Explorer", "🧠 Park Mind (PCA)", "🎟️ How Busy?"] ) with tab1: # KPI Row col1, col2, col3, col4 = st.columns(4) open_rides = filtered_latest[filtered_latest['is_open'] == True] avg_wait = open_rides['queue_time'].mean() max_wait_row = open_rides.loc[open_rides['queue_time'].idxmax()] if not open_rides.empty else None col1.metric("Rides Open", len(open_rides)) col2.metric("Avg Wait Time", f"{int(avg_wait)} min" if not pd.isna(avg_wait) else "N/A") if max_wait_row is not None: col3.metric("Longest Queue", f"{int(max_wait_row['queue_time'])} min", f"on {max_wait_row['name']}") else: col3.metric("Longest Queue", "N/A") col4.metric("Operational %", f"{int(len(open_rides)/len(filtered_latest)*100)}%" if len(filtered_latest) > 0 else "0%") # Map and Table mcol1, mcol2 = st.columns([2, 1]) with mcol1: st.subheader("Park Map") # Prepare data for map: drop rows without valid coordinates map_df = filtered_latest.dropna(subset=['lattitude', 'longitude']).copy() if not map_df.empty: # Handle NaN wait times for sizing: fill with 0 and set minimum size map_df['wait_size'] = map_df['queue_time'].fillna(0).apply(lambda x: max(float(x), 5.0)) # Plotly map for better control than st.map fig_map = px.scatter_mapbox( map_df, lat="lattitude", lon="longitude", color="queue_time", size="wait_size", hover_name="name", hover_data=["queue_status_message", "price"], color_continuous_scale="Viridis", zoom=15, height=600, mapbox_style="carto-darkmatter" ) fig_map.update_layout(margin={"r":0,"t":0,"l":0,"b":0}) st.plotly_chart(fig_map, use_container_width=True) else: st.info("No location data available to display map.") with mcol2: st.subheader("Live Board") board_df = filtered_latest[['name', 'queue_time', 'is_open', 'price']].sort_values('queue_time', ascending=False) st.dataframe( board_df, column_config={ "name": "Ride", "queue_time": st.column_config.NumberColumn("Wait (min)", format="%d ⏱️"), "is_open": "Status", "price": st.column_config.NumberColumn("Price", format="$%.2f") }, hide_index=True, use_container_width=True ) with tab2: st.subheader("Wait Time Trends (Last 48 Hours)") hist_df = fetch_historical_trends(days=2) if not hist_df.empty: # Filter historical data by selected categories hist_df = hist_df[hist_df['category'].isin(selected_cats)] # Aggregate by timestamp avg_hist = hist_df.groupby('time_stamp')['queue_time'].mean().reset_index() fig_trend = px.line( avg_hist, x='time_stamp', y='queue_time', title="Park-wide Average Wait Time", labels={'queue_time': 'Avg Wait (min)', 'time_stamp': 'Time'} ) fig_trend.update_traces(line_color="#2E8B57") st.plotly_chart(fig_trend, use_container_width=True) # Heatmap st.subheader("Busiest Times of Day") hist_df['hour'] = pd.to_datetime(hist_df['time_stamp']).dt.hour hist_df['day'] = pd.to_datetime(hist_df['time_stamp']).dt.day_name() heatmap_data = hist_df.groupby(['day', 'hour'])['queue_time'].mean().unstack().fillna(0) # Order days days_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] heatmap_data = heatmap_data.reindex(days_order) fig_heat = px.imshow( heatmap_data, labels=dict(x="Hour of Day", y="Day of Week", color="Avg Wait (min)"), x=heatmap_data.columns, y=heatmap_data.index, color_continuous_scale="YlGn" ) st.plotly_chart(fig_heat, use_container_width=True) else: st.info("No historical data available for the selected period.") with tab3: st.subheader("Ride Explorer") ride_names = sorted(latest_df['name'].unique()) selected_ride = st.selectbox("Select a Ride to Explore", options=ride_names) ride_info = latest_df[latest_df['name'] == selected_ride].iloc[0] ecol1, ecol2 = st.columns([1, 2]) with ecol1: st.markdown(f"### {ride_info['name']}") st.write(f"**Price:** ${ride_info['price']:.2f}") st.write(f"**Capacity:** {ride_info['capacity']} pph") st.write(f"**Duration:** {ride_info['duration']} sec") st.write(f"**Handstamp Included:** {'Yes' if ride_info['hand_stamp_included'] else 'No'}") st.write(f"**Height Requirement:** {ride_info['minimum_height_requirement']}\"") st.markdown("---") st.write(f"**Summary:** {ride_info['summary']}") if ride_info['restriction_summary']: st.warning(f"**Restrictions:** {ride_info['restriction_summary']}") with ecol2: st.subheader(f"7-Day History: {selected_ride}") # Fetch longer history for specific ride conn = get_db_connection() ride_id = ride_info['_id'] ride_hist_query = f"SELECT time_stamp, queue_time FROM dm_knb_live_data WHERE _id = {ride_id} AND time_stamp > NOW() - INTERVAL '7 days' ORDER BY time_stamp ASC" ride_hist_df = pd.read_sql(ride_hist_query, conn) conn.close() if not ride_hist_df.empty: # Convert to minutes ride_hist_df['queue_time'] = pd.to_numeric(ride_hist_df['queue_time'], errors='coerce') ride_hist_df['queue_time'] = (ride_hist_df['queue_time'] / 60.0).round(1) fig_ride_hist = px.area( ride_hist_df, x='time_stamp', y='queue_time', labels={'queue_time': 'Wait Time (min)', 'time_stamp': 'Time'} ) fig_ride_hist.update_traces(line_color="#2E8B57", fillcolor="rgba(46, 139, 87, 0.3)") st.plotly_chart(fig_ride_hist, use_container_width=True) else: st.info("No historical data found for this ride.") with tab4: st.subheader("🧠 What two seasons of wait times reveal about the park") st.markdown( "Feed every ride's wait-time history into **Principal Component Analysis** " "— with *no* labels for what any ride is or where it sits — and three " "hidden axes fall out on their own. They turn out to be **how busy the " "park is**, **which half of it you're in**, and **what time of day it is**. " "[Full write-up here.](https://blog.c0smere.net/reading-a-theme-parks-mindwith-pca/)" ) try: pca = compute_pca() except ImportError: st.error("`scikit-learn` isn't installed in this image yet — rebuild the " "dashboard container to enable the PCA tab.") pca = None if pca is None: st.info("Not enough data to compute the components right now.") else: loadings, scores, evr = pca["loadings"], pca["scores"], pca["evr"] # --- the three axes, with their explained variance --- st.markdown("#### The three axes PCA found") cols = st.columns(3) for col, pc in zip(cols, ["PC1", "PC2", "PC3"]): col.metric(f"{pc} · {PC_LABELS[pc]}", f"{evr[int(pc[-1]) - 1]:.1%}", help=PC_BLURB[pc]) st.caption(f"Just these 3 axes (out of {loadings.shape[0]}) capture " f"**{evr[:3].sum():.1%}** of all the variation in park wait times.") st.markdown("---") # --- the behavioral map: any two components, freely colored --- st.markdown("#### The map of the park, drawn from wait times alone") st.markdown( "Each ride placed by two of its component loadings — its *behavioral* " "position, drawn purely from how its queue moves over time. The " "default (**PC2 × PC3**, colored by category) is the write-up's view: " "Kiddieland huddles left, evening thrill rides sink bottom-right, " "daytime family rides float to the top. **Swap either axis** to any " "component, or **recolor by a third component's loading**, to hunt for " "structure the default hides." ) pcs = ["PC1", "PC2", "PC3", "PC4"] axis_labels = {p: f"{p} · {PC_LABELS[p]}" for p in pcs} axis_labels.update({"category": "Park's tag", "area": "Spatial area"}) mc1, mc2, mc3 = st.columns(3) with mc1: xpc = st.selectbox("X axis", pcs, index=1, key="map_x", format_func=lambda p: f"{p} — {PC_LABELS[p]}") with mc2: ypc = st.selectbox("Y axis", pcs, index=2, key="map_y", format_func=lambda p: f"{p} — {PC_LABELS[p]}") with mc3: color_opt = st.selectbox( "Color by", ["Category tag", "Spatial area"] + [f"{p} loading" for p in pcs], key="map_color") if xpc == ypc: st.info("Pick two *different* components for the axes.") else: plot_df = loadings.reset_index().copy() plot_df["category"] = plot_df["category"].fillna("Untagged") common = dict(x=xpc, y=ypc, text="ride_name", hover_name="ride_name", height=620, labels=axis_labels) if color_opt == "Category tag": fig_map_pca = px.scatter(plot_df, color="category", color_discrete_map={**CATEGORY_COLORS, "Untagged": "#888"}, **common) elif color_opt == "Spatial area": fig_map_pca = px.scatter(plot_df, color="area", **common) else: cpc = color_opt.split()[0] # "PC3 loading" -> "PC3" lim = plot_df[cpc].abs().max() # symmetric scale → white at 0 fig_map_pca = px.scatter(plot_df, color=cpc, color_continuous_scale="RdBu", range_color=[-lim, lim], **common) fig_map_pca.update_traces(textposition="top center", textfont=dict(size=9, color="#9aa0a6"), marker=dict(size=11, line=dict(width=1, color="#1A1C18"))) fig_map_pca.add_hline(y=0, line_width=0.5, line_color="grey") fig_map_pca.add_vline(x=0, line_width=0.5, line_color="grey") fig_map_pca.update_layout(legend=dict(orientation="h", yanchor="bottom", y=1.02)) st.plotly_chart(fig_map_pca, use_container_width=True) st.markdown("---") # --- the same idea in 3D: add PC1 as a third axis ------------------- st.markdown("#### The full 3-D picture") st.markdown( "The map above flattens out **PC1** (overall busyness) to fit on a " "page. Here are both clouds in their native 3-D — click-drag to spin, " "scroll to zoom. Two different things get plotted:" ) rides_tab, moments_tab = st.tabs( ["🎢 Rides in PC-space", "⏱️ Moments in PC-space"]) # ---- A) each RIDE as a point (the loadings) ---- with rides_tab: st.caption( "Every **ride** placed by how its queue behaves. Notice the cloud " "is nearly flat along **PC1** — almost every ride loads the same " "way on overall-busyness, so it barely separates them. All the " "real structure lives in the PC2/PC3 plane you saw above.") ride_color = st.selectbox( "Color the rides by", ["Park category tag", "Spatial area (GPS k-means)", "Overall busyness (PC1 loading)"], key="ride_color3d") rdf = loadings.reset_index().copy() rdf["category"] = rdf["category"].fillna("Untagged") common = dict(x="PC1", y="PC2", z="PC3", hover_name="ride_name", height=640) if ride_color == "Park category tag": fig3d_r = px.scatter_3d(rdf, color="category", color_discrete_map={**CATEGORY_COLORS, "Untagged": "#888"}, **common) elif ride_color == "Spatial area (GPS k-means)": fig3d_r = px.scatter_3d(rdf, color="area", **common) else: fig3d_r = px.scatter_3d(rdf, color="PC1", color_continuous_scale="Viridis", **common) fig3d_r.update_traces(marker=dict(size=5, line=dict(width=0.5, color="#1A1C18"))) fig3d_r.update_layout( margin=dict(l=0, r=0, t=0, b=0), scene=dict(xaxis_title="PC1 · busyness", yaxis_title="PC2 · Kiddieland↔midway", zaxis_title="PC3 · evening↔daytime")) st.plotly_chart(fig3d_r, use_container_width=True) # ---- B) each MOMENT as a point (the scores) ---- with moments_tab: st.caption( "Every **5-minute snapshot** of the park as one point — this is the " "direct analog of an embedding cloud, but each dot is a *moment*, " "not a note. Unlike the rides, the moments spread on all three " "axes: a packed evening sits far out on PC1, a kiddie-heavy morning " "pulls toward the PC2/PC3 corners.") mom_color = st.selectbox( "Color the moments by", ["Hour of day", "Day of week", "Weekend vs. weekday", "Month"], key="mom_color3d") sdf = scores.copy() idx = pd.DatetimeIndex(sdf.index) sdf["Hour of day"] = idx.hour sdf["Day of week"] = idx.day_name() sdf["Weekend vs. weekday"] = np.where(idx.weekday >= 5, "Weekend", "Weekday") sdf["Month"] = idx.month_name() # keep it responsive — sample if the history is large cap = 15000 note = "" if len(sdf) > cap: sdf = sdf.sample(cap, random_state=0) note = f" *(showing a random {cap:,} of {len(scores):,} snapshots)*" common_m = dict(x="PC1", y="PC2", z="PC3", height=640, opacity=0.55) if mom_color == "Hour of day": fig3d_m = px.scatter_3d(sdf, color="Hour of day", color_continuous_scale="Turbo", **common_m) elif mom_color == "Month": order = [pd.Timestamp(2020, m, 1).month_name() for m in range(1, 13)] fig3d_m = px.scatter_3d(sdf, color="Month", category_orders={"Month": order}, **common_m) elif mom_color == "Day of week": order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] fig3d_m = px.scatter_3d(sdf, color="Day of week", category_orders={"Day of week": order}, **common_m) else: fig3d_m = px.scatter_3d(sdf, color="Weekend vs. weekday", color_discrete_map={"Weekday": "#4C9BE8", "Weekend": "#E0524E"}, **common_m) fig3d_m.update_traces(marker=dict(size=2.5)) fig3d_m.update_layout( margin=dict(l=0, r=0, t=0, b=0), scene=dict(xaxis_title="PC1 · busyness", yaxis_title="PC2 · Kiddieland↔midway", zaxis_title="PC3 · evening↔daytime")) st.plotly_chart(fig3d_m, use_container_width=True) if note: st.caption(note.strip(" *")) st.markdown("---") # --- loadings explorer: which rides drive a chosen component --- lcol, rcol = st.columns([1, 2]) with lcol: st.markdown("#### Read an axis") pick = st.radio("Component", ["PC1", "PC2", "PC3", "PC4"], format_func=lambda p: f"{p} — {PC_LABELS[p]}") st.info(PC_BLURB[pick]) with rcol: ranked = loadings[[pick]].copy().sort_values(pick) ranked["ride"] = ranked.index lim = ranked[pick].abs().max() # symmetric so the scale's white sits at 0 fig_load = px.bar( ranked, x=pick, y="ride", orientation="h", color=pick, color_continuous_scale="RdBu", range_color=[-lim, lim], labels={pick: f"{pick} loading", "ride": ""}, height=max(420, 16 * len(ranked)), ) fig_load.update_layout(coloraxis_showscale=False, margin={"l": 0, "r": 0, "t": 10, "b": 0}) st.plotly_chart(fig_load, use_container_width=True) st.markdown("---") # --- the clock: each axis' average score by hour of day --- st.markdown("#### How each axis moves over a day") st.markdown( "Average score for each component by hour. **PC1** climbs into the " "evening as crowds build; **PC3** humps at midday (the school-group " "window) then falls — the fingerprint of *time of day*." ) by_hour = scores[["PC1", "PC2", "PC3"]].groupby(scores.index.hour).mean() by_hour.index.name = "hour" hour_long = by_hour.reset_index().melt("hour", var_name="Component", value_name="Mean score") fig_hour = px.line( hour_long, x="hour", y="Mean score", color="Component", markers=True, labels={"hour": "Hour of day (local)"}, color_discrete_map={"PC1": "#2E8B57", "PC2": "#E8A33D", "PC3": "#4C9BE8"}, ) fig_hour.add_hline(y=0, line_width=0.5, line_color="grey") st.plotly_chart(fig_hour, use_container_width=True) with tab5: st.subheader("🎟️ How busy is Knoebels right now?") st.markdown( "Two seasons of wait times sort the park into **four levels of crowding** " "(k-means on the park-wide average wait), from a quiet walk-on day to the " "rare zoo. A model rates the **current** moment — and a second model asks " "the harder question: *how busy* should *it be right now?*" ) try: B = compute_busyness_model() except ImportError: st.error("`scikit-learn` isn't installed in this image yet — rebuild the " "dashboard container to enable this tab.") B = None if B is None: st.info("Not enough data to train the busyness model right now.") else: # tier scale legend edges = [B["busy_min"], *B["bounds"], B["busy_max"]] legend = " · ".join( f"{TIER_EMOJI[i]} **{TIER_NAMES[i]}** ({edges[i]:.0f}–{edges[i+1]:.0f} min, " f"{B['tier_share'][i]:.0%})" for i in range(4)) st.caption("The four levels (avg wait across rides, share of all snapshots): " + legend) live = latest_ride_waits() st.markdown("---") gcol, ecol = st.columns([3, 2]) if live is None: gcol.info("No recent readings to rate.") else: waits, ts = live vec = waits.reindex(B["rides"]).fillna(0.0) # closed/missing = no line cur = float(vec.mean()) p = np.zeros(4) proba = B["board_clf"].predict_proba( B["board_scaler"].transform(vec.values.reshape(1, -1)))[0] for c, pp in zip(B["board_clf"].classes_, proba): p[int(c)] = pp tier = int(p.argmax()) with gcol: gauge = go.Figure(go.Indicator( mode="gauge+number", value=round(cur, 1), number={"suffix": " min", "font": {"size": 40}}, gauge={ "axis": {"range": [B["busy_min"], B["busy_max"]]}, "bar": {"color": "rgba(0,0,0,0)"}, "steps": [{"range": [edges[i], edges[i + 1]], "color": TIER_COLORS[i]} for i in range(4)], "threshold": {"line": {"color": "white", "width": 5}, "value": cur}, }, title={"text": f"{TIER_EMOJI[tier]} {TIER_NAMES[tier]}", "font": {"size": 26}}, )) gauge.update_layout(height=320, margin=dict(l=30, r=30, t=60, b=0)) st.plotly_chart(gauge, use_container_width=True) st.caption(f"Reading from **{ts:%b %d, %-I:%M %p}** · park-wide average " f"wait **{cur:.1f} min** · model confidence **{p[tier]:.0%}**") with ecol: st.markdown("##### Model's call") conf = pd.DataFrame({"Level": TIER_NAMES, "Probability": p}) fig_conf = px.bar(conf, x="Probability", y="Level", orientation="h", color="Level", color_discrete_sequence=TIER_COLORS, range_x=[0, 1]) fig_conf.update_layout(showlegend=False, height=200, yaxis={"categoryorder": "array", "categoryarray": TIER_NAMES[::-1]}, margin=dict(l=0, r=0, t=0, b=0)) st.plotly_chart(fig_conf, use_container_width=True) # expected for this day/time, from the calendar model Fn = _context_features(pd.DatetimeIndex([ts])).reindex( columns=B["cal_cols"], fill_value=0) exp = int(B["cal_clf"].predict(B["cal_scaler"].transform(Fn.values))[0]) verdict = ("about what you'd expect" if exp == tier else "**busier** than usual" if tier > exp else "**quieter** than usual") st.markdown( f"##### Expected vs. actual\n" f"For a **{ts:%A in %B}**, a typical moment is " f"**{TIER_NAMES[exp]}** {TIER_EMOJI[exp]}. Right now it's " f"**{TIER_NAMES[tier]}** {TIER_EMOJI[tier]} — {verdict}.") st.markdown("---") # --- bellwether rides + the honest model cards --- bcol, mcol = st.columns([2, 3]) with bcol: st.markdown("##### Bellwether rides") st.caption("Lines that most signal a *zoo* day, from the board model's " "coefficients. (Loadings are noisy under collinearity — read " "as a leaderboard, not exact weights.)") top = B["bellwether"].tail(10).iloc[::-1] fig_bell = px.bar(x=top.values, y=top.index, orientation="h", labels={"x": "coefficient (→ Zoo tier)", "y": ""}, color=top.values, color_continuous_scale="OrRd") fig_bell.update_layout(coloraxis_showscale=False, height=360, yaxis={"categoryorder": "total ascending"}, margin=dict(l=0, r=0, t=0, b=0)) st.plotly_chart(fig_bell, use_container_width=True) with mcol: st.markdown("##### Two models, two honest stories") m1, m2 = st.columns(2) m1.metric("Board-reading accuracy", f"{B['board_acc']:.1%}", help="Per-ride waits → tier. Near-perfect by construction — the " "tier is the mean of those very waits, so this just 'reads " "the board'. Great gauge, trivial as prediction.") m2.metric("Calendar-forecast accuracy", f"{B['cal_acc']:.1%}", delta=f"{B['cal_acc'] - B['baseline']:+.1%} vs. guessing", help="Hour/day/month/weekend → tier. The non-circular model.") st.markdown( f"**The chaos has a schedule.** From the calendar alone you *can't* " f"tell a quiet day from a pleasant one — crowd noise (weather, events, " f"luck) swamps it, so the forecast model ({B['cal_acc']:.0%}) barely " f"beats always-guessing ({B['baseline']:.0%}). **But it flags the zoo " f"days {B['zoo_recall']:.0%} of the time** — because Knoebels' worst " f"crowds are locked to specific October festival weekends. The everyday " f"is unpredictable; the extreme is on the calendar.") with st.expander("Calendar-model confusion matrix (where it succeeds & fails)"): cm = B["cal_cm"] cmn = cm / cm.sum(axis=1, keepdims=True) fig_cm = px.imshow(cmn, x=TIER_NAMES, y=TIER_NAMES, text_auto=".0%", color_continuous_scale="Blues", zmin=0, zmax=1, labels={"x": "predicted", "y": "actual", "color": "row share"}) fig_cm.update_layout(height=380, margin=dict(l=0, r=0, t=10, b=0)) st.plotly_chart(fig_cm, use_container_width=True) st.caption("Rows sum to 100%. Note the bottom-right cell: the rare " "'What were you thinking?!' tier is caught almost every time, " "while the three everyday tiers bleed into each other.") # --- Footer --- st.sidebar.markdown("---") st.sidebar.caption(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") if st.sidebar.button("Force Refresh Data"): st.cache_data.clear() st.rerun()