Files
kuh-no-bowls/app.py
T
wesandClaude Opus 4.7 e65d1c796c Name the GPS k-means areas by their marquee ride
Replace the "Area N" fallbacks with derived names: Kiddieland
(lowest mean PC2) plus The Woodies / The Grand Midway / Flying
Turns Grove / Giant Wheel Corner, each keyed off the headline
ride that lands in the cluster so the labels survive re-clustering
as the wait history grows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:35:42 -04:00

586 lines
25 KiB
Python

# 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",
}
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).",
}
CATEGORY_COLORS = {"Family": "#4C9BE8", "Kiddie": "#E8A33D", "Thrill": "#E0524E"}
@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=3)
sc = pca.fit_transform(scaled)
scores = pd.DataFrame(sc, index=matrix.index, columns=["PC1", "PC2", "PC3"])
loadings = pd.DataFrame(pca.components_.T, index=matrix.columns,
columns=["PC1", "PC2", "PC3"])
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_,
}
# --- 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 = st.tabs(
["📍 Live Status", "📈 Historical Trends", "🔍 Ride Explorer", "🧠 Park Mind (PCA)"]
)
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.sum():.1%}** of all the variation in park wait times.")
st.markdown("---")
# --- the behavioral map: PC2 vs PC3, colored by the park's own tag ---
st.markdown("#### The map of the park, drawn from wait times alone")
st.markdown(
"Each ride placed by its **PC2** (left↔right = Kiddieland↔midway) and "
"**PC3** (bottom↔top = evening↔daytime) loading — its *behavioral* "
"position — and colored by the park's category tag. The position comes "
"purely from how each queue moves over time, yet **the picture is the "
"park**: Kiddieland huddles left, evening thrill rides sink bottom-right, "
"daytime family rides float to the top."
)
plot_df = loadings.reset_index().copy()
plot_df["category"] = plot_df["category"].fillna("Untagged")
fig_map_pca = px.scatter(
plot_df, x="PC2", y="PC3", color="category", text="ride_name",
color_discrete_map=CATEGORY_COLORS,
labels={"PC2": "PC2 · Kiddieland ←→ main midway",
"PC3": "PC3 · evening crowd ←→ daytime crowd",
"category": "Park's tag"},
hover_name="ride_name", height=620,
)
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"],
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.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)
# --- 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()