diff --git a/app.py b/app.py index 96587a3..1e12b3c 100644 --- a/app.py +++ b/app.py @@ -112,6 +112,29 @@ PC_BLURB = { } 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(): @@ -206,6 +229,122 @@ def compute_pca(): "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("---") @@ -231,8 +370,9 @@ else: st.stop() # --- Tabs --- -tab1, tab2, tab3, tab4 = st.tabs( - ["πŸ“ Live Status", "πŸ“ˆ Historical Trends", "πŸ” Ride Explorer", "🧠 Park Mind (PCA)"] +tab1, tab2, tab3, tab4, tab5 = st.tabs( + ["πŸ“ Live Status", "πŸ“ˆ Historical Trends", "πŸ” Ride Explorer", + "🧠 Park Mind (PCA)", "🎟️ How Busy?"] ) with tab1: @@ -608,6 +748,141 @@ with tab4: 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')}")