Compare commits
9
Commits
7a92834c8b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a91e29ad | ||
|
|
5c09528df7 | ||
|
|
f7e0042ef8 | ||
|
|
ad163cb996 | ||
|
|
e65d1c796c | ||
|
|
53109bc7fe | ||
|
|
ade774f5aa | ||
|
|
dd6ff18b04 | ||
|
|
c247e15a4e |
@@ -5,3 +5,5 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
historical_data/
|
historical_data/
|
||||||
*.log
|
*.log
|
||||||
|
*.xapk
|
||||||
|
*.apk
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[theme]
|
||||||
|
primaryColor = "#2E8B57"
|
||||||
|
backgroundColor = "#1A1C18"
|
||||||
|
secondaryBackgroundColor = "#2D302A"
|
||||||
|
textColor = "#E6E6E6"
|
||||||
|
font = "sans serif"
|
||||||
+6
-1
@@ -2,9 +2,14 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY attractions_api_logger.py .
|
COPY . .
|
||||||
|
|
||||||
|
# Default to scraper, but can be overridden in docker-compose
|
||||||
ENTRYPOINT ["python", "-u", "attractions_api_logger.py"]
|
ENTRYPOINT ["python", "-u", "attractions_api_logger.py"]
|
||||||
|
|||||||
@@ -0,0 +1,891 @@
|
|||||||
|
# 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]} <b>{TIER_NAMES[tier]}</b>",
|
||||||
|
"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()
|
||||||
@@ -3,12 +3,57 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
image: kuh-no-bowls:latest
|
image: kuh-no-bowls:latest
|
||||||
container_name: kuh-no-bowls-scraper
|
container_name: kuh-no-bowls-scraper
|
||||||
|
profiles: ["oneshot"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
LOG_PATH: /var/log/kuh-no-bowls/kuh-no-bowls.log
|
LOG_PATH: /var/log/kuh-no-bowls/kuh-no-bowls.log
|
||||||
TZ: America/New_York
|
TZ: America/New_York
|
||||||
volumes:
|
volumes:
|
||||||
- knb_logs:/var/log/kuh-no-bowls
|
- knb_logs:/var/log/kuh-no-bowls
|
||||||
|
networks:
|
||||||
|
- services_net
|
||||||
|
|
||||||
|
# Hourly Open-Meteo weather -> knoebels."LZ_open_meteo_hourly".
|
||||||
|
# Oneshot, run from cron. Default mode does the recent sync; pass --backfill
|
||||||
|
# once to load history: docker compose --profile oneshot run --rm weather --backfill
|
||||||
|
weather:
|
||||||
|
build: .
|
||||||
|
image: kuh-no-bowls:latest
|
||||||
|
container_name: kuh-no-bowls-weather
|
||||||
|
profiles: ["oneshot"]
|
||||||
|
entrypoint: ["python", "-u", "weather_logger.py"]
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
LOG_PATH: /var/log/kuh-no-bowls/weather.log
|
||||||
|
TZ: America/New_York
|
||||||
|
volumes:
|
||||||
|
- knb_logs:/var/log/kuh-no-bowls
|
||||||
|
networks:
|
||||||
|
- services_net
|
||||||
|
|
||||||
|
dashboard:
|
||||||
|
build: .
|
||||||
|
image: kuh-no-bowls:latest
|
||||||
|
container_name: kuh-no-bowls-dashboard
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
entrypoint: ["streamlit", "run", "app.py", "--server.port=8502", "--server.address=0.0.0.0"]
|
||||||
|
ports:
|
||||||
|
- "8502:8502"
|
||||||
|
volumes:
|
||||||
|
- ./app.py:/app/app.py:ro
|
||||||
|
- ./.streamlit:/app/.streamlit:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:8502/_stcore/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- services_net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
knb_logs:
|
knb_logs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
services_net:
|
||||||
|
external: true
|
||||||
|
|||||||
+166
-118
@@ -1,118 +1,166 @@
|
|||||||
'''
|
#!/usr/bin/env python3
|
||||||
Importing POI data from attractions.io api
|
"""Import POI and category data from the attractions.io records bundle."""
|
||||||
'''
|
|
||||||
#!/usr/bin/env python3
|
import io
|
||||||
|
import json
|
||||||
from datetime import datetime, date
|
import logging
|
||||||
import json, requests, psycopg2
|
import os
|
||||||
from psycopg2 import sql
|
import sys
|
||||||
|
import uuid
|
||||||
db_params = {
|
import zipfile
|
||||||
'dbname': 'gp0',
|
from datetime import datetime, timezone
|
||||||
'user': 'kuhnobowls',
|
|
||||||
'password': 'saddog095',
|
import psycopg2
|
||||||
'host': '192.168.88.5',
|
import requests
|
||||||
'port': '5432',
|
from psycopg2.extras import execute_values
|
||||||
'options': '-c search_path=knoebels'
|
|
||||||
}
|
API_BASE = 'https://api.attractions.io/v1'
|
||||||
|
API_KEY = '72f0ea9e-d196-508a-bee8-cce62c3228c7' # Knoebels app id (BuildConfig.API_KEY)
|
||||||
def log_to_database(table_name, data):
|
APP_VERSION = '1.3.2' # bump if a newer xapk reveals drift
|
||||||
with psycopg2.connect(**db_params) as conn:
|
APP_BUILD = 70
|
||||||
with conn.cursor() as cursor:
|
NATIVE_VERSION = '1.0.129+6c346f2'
|
||||||
columns = len(data[0])
|
|
||||||
query = sql.SQL("INSERT INTO {table} VALUES ({values})").format(
|
DB_PASSWORD = os.environ.get('DB_PASSWORD')
|
||||||
table=sql.Identifier(table_name),
|
if not DB_PASSWORD:
|
||||||
values=sql.SQL(', ').join(sql.Placeholder() * columns)
|
sys.stderr.write("ERROR: DB_PASSWORD env var is required\n")
|
||||||
)
|
sys.exit(1)
|
||||||
|
|
||||||
cursor.executemany(query, data)
|
db_params = {
|
||||||
conn.commit()
|
'dbname': 'gp0',
|
||||||
|
'user': os.environ.get('DB_USER', 'kuhnobowls'),
|
||||||
def generic_query(q):
|
'password': DB_PASSWORD,
|
||||||
ret = []
|
'host': os.environ.get('DB_HOST', '192.168.88.9'),
|
||||||
with psycopg2.connect(**db_params) as conn:
|
'port': os.environ.get('DB_PORT', '5432'),
|
||||||
with conn.cursor() as cursor:
|
'options': '-c search_path=knoebels',
|
||||||
cursor.execute(q)
|
}
|
||||||
rows = cursor.fetchall()
|
|
||||||
col_names = [desc[0] for desc in cursor.description]
|
CATEGORY_COLUMNS = ('_id', 'name', 'parent')
|
||||||
|
|
||||||
for row in rows:
|
POI_COLUMNS = (
|
||||||
ret.append(row)
|
'_id', 'name', 'abbreviated_name', 'summary', 'keywords', 'default_image',
|
||||||
|
'location', 'featured', 'wayfinding_enabled', 'visible_on_map', 'category',
|
||||||
return ret
|
'parent', 'menu_url', 'minimum_height_requirement',
|
||||||
|
'minimum_unaccompanied_height_requirement', 'maximum_height_requirement',
|
||||||
data = None
|
'minimum_age_requirement', 'minimum_unaccompanied_age_requirement',
|
||||||
|
'maximum_age_requirement', 'restriction_summary',
|
||||||
with open('records.json', 'r', encoding='utf-8') as file:
|
)
|
||||||
data = json.load(file)
|
|
||||||
|
|
||||||
rows = []
|
def _occasio_headers(installation_token=None):
|
||||||
|
auth = f'Attractions-Io api-key="{API_KEY}"'
|
||||||
for poi in data['Item']:
|
if installation_token:
|
||||||
aoi_id = poi['_id']
|
auth += f', installation-token="{installation_token}"'
|
||||||
Name = poi['Name']
|
return {
|
||||||
AbbreviatedName = poi['AbbreviatedName']
|
'Authorization': auth,
|
||||||
Summary = poi['Summary']
|
'X-Idempotency-Key': str(uuid.uuid4()),
|
||||||
Keywords = poi['Keywords']
|
'Date': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||||
DefaultImage = poi['DefaultImage']
|
'Occasio-Platform': 'Android',
|
||||||
Location = poi['Location']
|
'Occasio-Platform-Version': '14',
|
||||||
Featured = poi['Featured']
|
'Occasio-App-Version': APP_VERSION,
|
||||||
WayfindingEnabled = poi['WayfindingEnabled']
|
'Occasio-App-Build': str(APP_BUILD),
|
||||||
VisibleOnMap = poi['VisibleOnMap']
|
'Occasio-Native-Version': NATIVE_VERSION,
|
||||||
Category = poi['Category']
|
}
|
||||||
Parent = poi['Parent']
|
|
||||||
|
|
||||||
try:
|
def fetch_records_bundle():
|
||||||
MenuURL = poi['MenuURL']
|
"""Two-step Occasio data fetch: register an installation, then pull the data zip."""
|
||||||
except:
|
install_resp = requests.post(
|
||||||
MenuURL = None
|
f'{API_BASE}/installation',
|
||||||
|
headers=_occasio_headers(),
|
||||||
try:
|
files={
|
||||||
MinimumHeightRequirement = poi['MinimumHeightRequirement']
|
'device_identifier': (None, '123'),
|
||||||
except:
|
'user_identifier': (None, str(uuid.uuid4())),
|
||||||
MinimumHeightRequirement = None
|
'app_build': (None, str(APP_BUILD)),
|
||||||
|
'app_version': (None, APP_VERSION),
|
||||||
try:
|
},
|
||||||
MinimumUnaccompaniedHeightRequirement = poi['MinimumUnaccompaniedHeightRequirement']
|
)
|
||||||
except:
|
install_resp.raise_for_status()
|
||||||
MinimumUnaccompaniedHeightRequirement = None
|
token = install_resp.json()['token']
|
||||||
|
|
||||||
try:
|
data_resp = requests.get(
|
||||||
MaximumHeightRequirement = poi['MaximumHeightRequirement']
|
f'{API_BASE}/data',
|
||||||
except:
|
headers=_occasio_headers(installation_token=token),
|
||||||
MaximumHeightRequirement = None
|
)
|
||||||
|
data_resp.raise_for_status()
|
||||||
try:
|
|
||||||
MinimumAgeRequirement = poi['MinimumAgeRequirement']
|
with zipfile.ZipFile(io.BytesIO(data_resp.content)) as zf:
|
||||||
except:
|
with zf.open('records.json') as f:
|
||||||
MinimumAgeRequirement = None
|
return json.load(f)
|
||||||
|
|
||||||
try:
|
|
||||||
MinimumUnaccompaniedAgeRequirement = poi['MinimumUnaccompaniedAgeRequirement']
|
def _location_to_point(loc):
|
||||||
except:
|
# Location arrives as 'lat,lon'; Postgres point input wants '(x,y)'.
|
||||||
MinimumUnaccompaniedAgeRequirement = None
|
if not loc:
|
||||||
|
return None
|
||||||
try:
|
return f'({loc})'
|
||||||
MaximumAgeRequirement = poi['MaximumAgeRequirement']
|
|
||||||
except:
|
|
||||||
MaximumAgeRequirement = None
|
def _category_row(cat):
|
||||||
|
return (cat['_id'], cat['Name'], cat.get('Parent'))
|
||||||
try:
|
|
||||||
RestrictionSummary = poi['RestrictionSummary']
|
|
||||||
except:
|
def _poi_row(poi):
|
||||||
RestrictionSummary = None
|
return (
|
||||||
|
poi['_id'],
|
||||||
row = [aoi_id, Name, AbbreviatedName, Summary, Keywords, DefaultImage, Location, Featured, WayfindingEnabled, VisibleOnMap, Category, Parent, MenuURL, MinimumHeightRequirement, MinimumUnaccompaniedHeightRequirement, MaximumHeightRequirement, MinimumAgeRequirement, MinimumUnaccompaniedAgeRequirement, MaximumAgeRequirement, RestrictionSummary]
|
poi.get('Name'),
|
||||||
rows.append(row)
|
poi.get('AbbreviatedName'),
|
||||||
|
poi.get('Summary'),
|
||||||
cats = []
|
poi.get('Keywords'),
|
||||||
|
poi.get('DefaultImage'),
|
||||||
for cat in data['Category']:
|
_location_to_point(poi.get('Location')),
|
||||||
aoi_id = cat['_id']
|
poi.get('Featured'),
|
||||||
Name = cat['Name']
|
poi.get('WayfindingEnabled'),
|
||||||
Parent = cat['Parent']
|
poi.get('VisibleOnMap'),
|
||||||
row = [aoi_id, Name, Parent]
|
poi.get('Category'),
|
||||||
cats.append(row)
|
poi.get('Parent'),
|
||||||
|
poi.get('MenuURL'),
|
||||||
log_to_database('LZ_attractions_io_categories', cats)
|
poi.get('MinimumHeightRequirement'),
|
||||||
#log_to_database('LZ_attractions_io_poi', rows)
|
poi.get('MinimumUnaccompaniedHeightRequirement'),
|
||||||
|
poi.get('MaximumHeightRequirement'),
|
||||||
|
poi.get('MinimumAgeRequirement'),
|
||||||
|
poi.get('MinimumUnaccompaniedAgeRequirement'),
|
||||||
|
poi.get('MaximumAgeRequirement'),
|
||||||
|
poi.get('RestrictionSummary'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_rows(conn, table, columns, rows, *, location_column=None):
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
cols_sql = ', '.join(f'"{c}"' for c in columns)
|
||||||
|
update_sql = ', '.join(f'"{c}" = EXCLUDED."{c}"' for c in columns if c != '_id')
|
||||||
|
# Force the 'point' cast on the location column so multi-row VALUES doesn't
|
||||||
|
# fall back to inferring the type as text.
|
||||||
|
placeholders = ', '.join(
|
||||||
|
'%s::point' if c == location_column else '%s' for c in columns
|
||||||
|
)
|
||||||
|
template = f'({placeholders})'
|
||||||
|
query = (
|
||||||
|
f'INSERT INTO knoebels."{table}" ({cols_sql}) VALUES %s '
|
||||||
|
f'ON CONFLICT (_id) DO UPDATE SET {update_sql}'
|
||||||
|
)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
execute_values(cur, query, rows, template=template)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', level=logging.INFO)
|
||||||
|
|
||||||
|
logging.info('Fetching records bundle from attractions.io...')
|
||||||
|
data = fetch_records_bundle()
|
||||||
|
logging.info('Got %d Items, %d Categories', len(data['Item']), len(data['Category']))
|
||||||
|
|
||||||
|
cats = [_category_row(c) for c in data['Category']]
|
||||||
|
pois = [_poi_row(p) for p in data['Item']]
|
||||||
|
|
||||||
|
with psycopg2.connect(**db_params) as conn:
|
||||||
|
n_cats = upsert_rows(conn, 'LZ_attractions_io_categories', CATEGORY_COLUMNS, cats)
|
||||||
|
n_pois = upsert_rows(conn, 'LZ_attractions_io_poi', POI_COLUMNS, pois,
|
||||||
|
location_column='location')
|
||||||
|
conn.commit()
|
||||||
|
logging.info('Upserted %d categories, %d POIs', n_cats, n_pois)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
.ghost
|
||||||
|
post_assets/
|
||||||
|
preview.html
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
|
|||||||
|
-- Full queue-time time series joined to hourly Open-Meteo weather.
|
||||||
|
-- Queue readings (sub-hour, every ~5 min) are bucketed to the hour and matched
|
||||||
|
-- to knoebels."LZ_open_meteo_hourly". Both sides are naive America/New_York, so
|
||||||
|
-- the hour buckets line up directly with no timezone math. LEFT JOIN keeps every
|
||||||
|
-- queue row even if an hour's weather is missing (e.g. the once-a-year DST hour).
|
||||||
|
SELECT a.time_stamp,
|
||||||
|
a._id AS ride_id,
|
||||||
|
TRIM(r.name) AS ride_name,
|
||||||
|
TRIM(c."name") AS category,
|
||||||
|
ROUND((r.minimum_height_requirement * 39.37)::numeric, 0) AS minimum_height_requirement_inches,
|
||||||
|
ROUND((r.minimum_unaccompanied_height_requirement * 39.37)::numeric, 0) AS minimum_unaccompanied_height_requirement_inches,
|
||||||
|
ROUND((r.maximum_height_requirement * 39.37)::numeric, 0) AS maximum_height_requirement_inches,
|
||||||
|
r."location" AS coordinates,
|
||||||
|
a.queue_time AS queue_time_sec,
|
||||||
|
w.temperature_2m AS temperature_f,
|
||||||
|
w.apparent_temperature AS feels_like_f,
|
||||||
|
w.relative_humidity_2m AS humidity_pct,
|
||||||
|
w.precipitation AS precipitation_in,
|
||||||
|
w.rain AS rain_in,
|
||||||
|
w.weather_code AS wmo_weather_code,
|
||||||
|
CASE w.weather_code
|
||||||
|
WHEN 0 THEN 'Clear sky'
|
||||||
|
WHEN 1 THEN 'Mainly clear'
|
||||||
|
WHEN 2 THEN 'Partly cloudy'
|
||||||
|
WHEN 3 THEN 'Overcast'
|
||||||
|
WHEN 45 THEN 'Fog'
|
||||||
|
WHEN 48 THEN 'Depositing rime fog'
|
||||||
|
WHEN 51 THEN 'Light drizzle'
|
||||||
|
WHEN 53 THEN 'Moderate drizzle'
|
||||||
|
WHEN 55 THEN 'Dense drizzle'
|
||||||
|
WHEN 56 THEN 'Light freezing drizzle'
|
||||||
|
WHEN 57 THEN 'Dense freezing drizzle'
|
||||||
|
WHEN 61 THEN 'Slight rain'
|
||||||
|
WHEN 63 THEN 'Moderate rain'
|
||||||
|
WHEN 65 THEN 'Heavy rain'
|
||||||
|
WHEN 66 THEN 'Light freezing rain'
|
||||||
|
WHEN 67 THEN 'Heavy freezing rain'
|
||||||
|
WHEN 71 THEN 'Slight snowfall'
|
||||||
|
WHEN 73 THEN 'Moderate snowfall'
|
||||||
|
WHEN 75 THEN 'Heavy snowfall'
|
||||||
|
WHEN 77 THEN 'Snow grains'
|
||||||
|
WHEN 80 THEN 'Slight rain showers'
|
||||||
|
WHEN 81 THEN 'Moderate rain showers'
|
||||||
|
WHEN 82 THEN 'Violent rain showers'
|
||||||
|
WHEN 85 THEN 'Slight snow showers'
|
||||||
|
WHEN 86 THEN 'Heavy snow showers'
|
||||||
|
WHEN 95 THEN 'Thunderstorm'
|
||||||
|
WHEN 96 THEN 'Thunderstorm with slight hail'
|
||||||
|
WHEN 99 THEN 'Thunderstorm with heavy hail'
|
||||||
|
END AS weather_description,
|
||||||
|
w.cloud_cover AS cloud_cover_pct,
|
||||||
|
w.wind_speed_10m AS wind_mph,
|
||||||
|
w.wind_gusts_10m AS wind_gust_mph
|
||||||
|
FROM knoebels."LZ_attractions_io" a
|
||||||
|
JOIN knoebels."LZ_attractions_io_poi" r
|
||||||
|
ON r._id = a._id
|
||||||
|
JOIN knoebels."LZ_attractions_io_categories" c
|
||||||
|
ON r.category = c._id
|
||||||
|
LEFT JOIN knoebels."LZ_open_meteo_hourly" w
|
||||||
|
ON w."time" = date_trunc('hour', a.time_stamp)
|
||||||
|
WHERE a.queue_time IS NOT NULL;
|
||||||
@@ -1,2 +1,8 @@
|
|||||||
requests
|
requests
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
|
streamlit
|
||||||
|
pandas
|
||||||
|
numpy
|
||||||
|
plotly
|
||||||
|
python-dotenv
|
||||||
|
scikit-learn
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Knoebels Open-Meteo hourly weather sync (one-shot)
|
||||||
|
After=docker.service network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/home/nox/docker/kuh-no-bowls
|
||||||
|
# `compose run` activates the service regardless of its compose profile.
|
||||||
|
# Default (no args) = recent sync (Forecast API, past_days=7, self-healing).
|
||||||
|
# One-time history load was done manually with: ... run --rm weather --backfill
|
||||||
|
ExecStart=/usr/bin/docker compose run --rm weather
|
||||||
|
StandardOutput=append:/home/nox/docker/kuh-no-bowls/cron-logs/weather-cron.log
|
||||||
|
StandardError=append:/home/nox/docker/kuh-no-bowls/cron-logs/weather-cron.log
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run the Knoebels weather sync every 6 hours
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Every 6 hours (00/06/12/18 ET). past_days=7 means each run refreshes the
|
||||||
|
# last week, so a missed run self-heals and provisional values get refined.
|
||||||
|
OnCalendar=*-*-* 00/6:00:00
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=300
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fetch hourly weather for Knoebels (Elysburg, PA) from Open-Meteo and upsert
|
||||||
|
into knoebels."LZ_open_meteo_hourly".
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
(default) recent -- Forecast API with past_days; keeps the table current and
|
||||||
|
self-heals recent gaps. This is what the daily cron runs.
|
||||||
|
--backfill -- Archive (ERA5 reanalysis) API back to --start
|
||||||
|
(default 2024-05-15, when collection began).
|
||||||
|
|
||||||
|
Timestamps are stored as naive America/New_York to match how the rest of the
|
||||||
|
knoebels schema records time. Upserts are idempotent via ON CONFLICT (time),
|
||||||
|
so rerunning overwrites provisional values with better data as it arrives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import requests
|
||||||
|
from psycopg2.extras import execute_values
|
||||||
|
|
||||||
|
# Knoebels Amusement Resort, Elysburg PA
|
||||||
|
LATITUDE = 40.7906
|
||||||
|
LONGITUDE = -76.4847
|
||||||
|
COLLECTION_START = "2024-05-15" # first day of queue-time collection
|
||||||
|
TIMEZONE = "America/New_York"
|
||||||
|
|
||||||
|
ARCHIVE_ENDPOINT = "https://archive-api.open-meteo.com/v1/archive"
|
||||||
|
FORECAST_ENDPOINT = "https://api.open-meteo.com/v1/forecast"
|
||||||
|
|
||||||
|
# Hourly variables available identically in both the archive and forecast APIs.
|
||||||
|
HOURLY_VARS = [
|
||||||
|
"temperature_2m",
|
||||||
|
"relative_humidity_2m",
|
||||||
|
"apparent_temperature",
|
||||||
|
"precipitation",
|
||||||
|
"rain",
|
||||||
|
"weather_code",
|
||||||
|
"cloud_cover",
|
||||||
|
"wind_speed_10m",
|
||||||
|
"wind_gusts_10m",
|
||||||
|
]
|
||||||
|
|
||||||
|
COMMON_PARAMS = {
|
||||||
|
"latitude": LATITUDE,
|
||||||
|
"longitude": LONGITUDE,
|
||||||
|
"hourly": ",".join(HOURLY_VARS),
|
||||||
|
"temperature_unit": "fahrenheit",
|
||||||
|
"precipitation_unit": "inch",
|
||||||
|
"wind_speed_unit": "mph",
|
||||||
|
"timezone": TIMEZONE,
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_PATH = os.environ.get("LOG_PATH") # unset -> log to stderr
|
||||||
|
|
||||||
|
DB_PASSWORD = os.environ.get("DB_PASSWORD")
|
||||||
|
if not DB_PASSWORD:
|
||||||
|
sys.stderr.write("ERROR: DB_PASSWORD env var is required\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
db_params = {
|
||||||
|
"dbname": os.environ.get("DB_NAME", "gp0"),
|
||||||
|
"user": os.environ.get("DB_USER", "kuhnobowls"),
|
||||||
|
"password": DB_PASSWORD,
|
||||||
|
"host": os.environ.get("DB_HOST", "192.168.88.9"),
|
||||||
|
"port": os.environ.get("DB_PORT", "5432"),
|
||||||
|
"options": "-c search_path=knoebels",
|
||||||
|
}
|
||||||
|
|
||||||
|
CREATE_TABLE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS knoebels."LZ_open_meteo_hourly" (
|
||||||
|
"time" timestamp without time zone PRIMARY KEY,
|
||||||
|
temperature_2m real,
|
||||||
|
relative_humidity_2m smallint,
|
||||||
|
apparent_temperature real,
|
||||||
|
precipitation real,
|
||||||
|
rain real,
|
||||||
|
weather_code smallint,
|
||||||
|
cloud_cover smallint,
|
||||||
|
wind_speed_10m real,
|
||||||
|
wind_gusts_10m real,
|
||||||
|
inserted_at timestamp without time zone DEFAULT now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
UPSERT = """
|
||||||
|
INSERT INTO knoebels."LZ_open_meteo_hourly"
|
||||||
|
("time", temperature_2m, relative_humidity_2m, apparent_temperature,
|
||||||
|
precipitation, rain, weather_code, cloud_cover, wind_speed_10m, wind_gusts_10m)
|
||||||
|
VALUES %s
|
||||||
|
ON CONFLICT ("time") DO UPDATE SET
|
||||||
|
temperature_2m = EXCLUDED.temperature_2m,
|
||||||
|
relative_humidity_2m = EXCLUDED.relative_humidity_2m,
|
||||||
|
apparent_temperature = EXCLUDED.apparent_temperature,
|
||||||
|
precipitation = EXCLUDED.precipitation,
|
||||||
|
rain = EXCLUDED.rain,
|
||||||
|
weather_code = EXCLUDED.weather_code,
|
||||||
|
cloud_cover = EXCLUDED.cloud_cover,
|
||||||
|
wind_speed_10m = EXCLUDED.wind_speed_10m,
|
||||||
|
wind_gusts_10m = EXCLUDED.wind_gusts_10m,
|
||||||
|
inserted_at = now()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(endpoint, extra):
|
||||||
|
params = dict(COMMON_PARAMS, **extra)
|
||||||
|
resp = requests.get(endpoint, params=params, timeout=60)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def rows_from_payload(payload):
|
||||||
|
"""Turn an Open-Meteo payload into row tuples, dropping all-null hours
|
||||||
|
(the archive API pads the last few days with nulls until ERA5 lands)."""
|
||||||
|
hourly = payload["hourly"]
|
||||||
|
times = hourly["time"]
|
||||||
|
cols = [hourly[var] for var in HOURLY_VARS]
|
||||||
|
rows = []
|
||||||
|
for i, t in enumerate(times):
|
||||||
|
values = [col[i] for col in cols]
|
||||||
|
if all(v is None for v in values):
|
||||||
|
continue
|
||||||
|
rows.append((datetime.fromisoformat(t), *values))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def upsert(conn, rows):
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
execute_values(cur, UPSERT, rows, page_size=1000)
|
||||||
|
conn.commit()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def run_recent(conn, past_days, forecast_days):
|
||||||
|
payload = fetch(FORECAST_ENDPOINT,
|
||||||
|
{"past_days": past_days, "forecast_days": forecast_days})
|
||||||
|
rows = rows_from_payload(payload)
|
||||||
|
n = upsert(conn, rows)
|
||||||
|
span = f"{rows[0][0]} .. {rows[-1][0]}" if rows else "(none)"
|
||||||
|
logging.info("recent: upserted %d hours [%s]", n, span)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def run_backfill(conn, start):
|
||||||
|
end = date.today().isoformat()
|
||||||
|
payload = fetch(ARCHIVE_ENDPOINT, {"start_date": start, "end_date": end})
|
||||||
|
rows = rows_from_payload(payload)
|
||||||
|
n = upsert(conn, rows)
|
||||||
|
span = f"{rows[0][0]} .. {rows[-1][0]}" if rows else "(none)"
|
||||||
|
logging.info("backfill: upserted %d hours [%s]", n, span)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Open-Meteo -> knoebels weather logger")
|
||||||
|
parser.add_argument("--backfill", action="store_true",
|
||||||
|
help="pull the full ERA5 archive history before the recent sync")
|
||||||
|
parser.add_argument("--start", default=COLLECTION_START,
|
||||||
|
help="backfill start date (YYYY-MM-DD)")
|
||||||
|
parser.add_argument("--past-days", type=int, default=7,
|
||||||
|
help="forecast-API lookback for the recent sync (max 92)")
|
||||||
|
parser.add_argument("--forecast-days", type=int, default=1,
|
||||||
|
help="forecast-API lookahead for the recent sync (0-16)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
|
level=logging.INFO,
|
||||||
|
filename=LOG_PATH,
|
||||||
|
filemode="a" if LOG_PATH else None,
|
||||||
|
)
|
||||||
|
logging.info("-" * 80)
|
||||||
|
|
||||||
|
with psycopg2.connect(**db_params) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(CREATE_TABLE)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if args.backfill:
|
||||||
|
run_backfill(conn, args.start)
|
||||||
|
# Always finish with a recent sync so the ERA5 gap (last ~5 days) is
|
||||||
|
# filled and the table is current.
|
||||||
|
run_recent(conn, args.past_days, args.forecast_days)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user