Compare commits
9
Commits
7a92834c8b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a91e29ad | ||
|
|
5c09528df7 | ||
|
|
f7e0042ef8 | ||
|
|
ad163cb996 | ||
|
|
e65d1c796c | ||
|
|
53109bc7fe | ||
|
|
ade774f5aa | ||
|
|
dd6ff18b04 | ||
|
|
c247e15a4e |
@@ -5,3 +5,5 @@ __pycache__/
|
||||
venv/
|
||||
historical_data/
|
||||
*.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
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY 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"]
|
||||
|
||||
@@ -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: .
|
||||
image: kuh-no-bowls:latest
|
||||
container_name: kuh-no-bowls-scraper
|
||||
profiles: ["oneshot"]
|
||||
env_file: .env
|
||||
environment:
|
||||
LOG_PATH: /var/log/kuh-no-bowls/kuh-no-bowls.log
|
||||
TZ: America/New_York
|
||||
volumes:
|
||||
- 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:
|
||||
knb_logs:
|
||||
|
||||
networks:
|
||||
services_net:
|
||||
external: true
|
||||
|
||||
+166
-118
@@ -1,118 +1,166 @@
|
||||
'''
|
||||
Importing POI data from attractions.io api
|
||||
'''
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from datetime import datetime, date
|
||||
import json, requests, psycopg2
|
||||
from psycopg2 import sql
|
||||
|
||||
db_params = {
|
||||
'dbname': 'gp0',
|
||||
'user': 'kuhnobowls',
|
||||
'password': 'saddog095',
|
||||
'host': '192.168.88.5',
|
||||
'port': '5432',
|
||||
'options': '-c search_path=knoebels'
|
||||
}
|
||||
|
||||
def log_to_database(table_name, data):
|
||||
with psycopg2.connect(**db_params) as conn:
|
||||
with conn.cursor() as cursor:
|
||||
columns = len(data[0])
|
||||
query = sql.SQL("INSERT INTO {table} VALUES ({values})").format(
|
||||
table=sql.Identifier(table_name),
|
||||
values=sql.SQL(', ').join(sql.Placeholder() * columns)
|
||||
)
|
||||
|
||||
cursor.executemany(query, data)
|
||||
conn.commit()
|
||||
|
||||
def generic_query(q):
|
||||
ret = []
|
||||
with psycopg2.connect(**db_params) as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(q)
|
||||
rows = cursor.fetchall()
|
||||
col_names = [desc[0] for desc in cursor.description]
|
||||
|
||||
for row in rows:
|
||||
ret.append(row)
|
||||
|
||||
return ret
|
||||
|
||||
data = None
|
||||
|
||||
with open('records.json', 'r', encoding='utf-8') as file:
|
||||
data = json.load(file)
|
||||
|
||||
rows = []
|
||||
|
||||
for poi in data['Item']:
|
||||
aoi_id = poi['_id']
|
||||
Name = poi['Name']
|
||||
AbbreviatedName = poi['AbbreviatedName']
|
||||
Summary = poi['Summary']
|
||||
Keywords = poi['Keywords']
|
||||
DefaultImage = poi['DefaultImage']
|
||||
Location = poi['Location']
|
||||
Featured = poi['Featured']
|
||||
WayfindingEnabled = poi['WayfindingEnabled']
|
||||
VisibleOnMap = poi['VisibleOnMap']
|
||||
Category = poi['Category']
|
||||
Parent = poi['Parent']
|
||||
|
||||
try:
|
||||
MenuURL = poi['MenuURL']
|
||||
except:
|
||||
MenuURL = None
|
||||
|
||||
try:
|
||||
MinimumHeightRequirement = poi['MinimumHeightRequirement']
|
||||
except:
|
||||
MinimumHeightRequirement = None
|
||||
|
||||
try:
|
||||
MinimumUnaccompaniedHeightRequirement = poi['MinimumUnaccompaniedHeightRequirement']
|
||||
except:
|
||||
MinimumUnaccompaniedHeightRequirement = None
|
||||
|
||||
try:
|
||||
MaximumHeightRequirement = poi['MaximumHeightRequirement']
|
||||
except:
|
||||
MaximumHeightRequirement = None
|
||||
|
||||
try:
|
||||
MinimumAgeRequirement = poi['MinimumAgeRequirement']
|
||||
except:
|
||||
MinimumAgeRequirement = None
|
||||
|
||||
try:
|
||||
MinimumUnaccompaniedAgeRequirement = poi['MinimumUnaccompaniedAgeRequirement']
|
||||
except:
|
||||
MinimumUnaccompaniedAgeRequirement = None
|
||||
|
||||
try:
|
||||
MaximumAgeRequirement = poi['MaximumAgeRequirement']
|
||||
except:
|
||||
MaximumAgeRequirement = None
|
||||
|
||||
try:
|
||||
RestrictionSummary = poi['RestrictionSummary']
|
||||
except:
|
||||
RestrictionSummary = None
|
||||
|
||||
row = [aoi_id, Name, AbbreviatedName, Summary, Keywords, DefaultImage, Location, Featured, WayfindingEnabled, VisibleOnMap, Category, Parent, MenuURL, MinimumHeightRequirement, MinimumUnaccompaniedHeightRequirement, MaximumHeightRequirement, MinimumAgeRequirement, MinimumUnaccompaniedAgeRequirement, MaximumAgeRequirement, RestrictionSummary]
|
||||
rows.append(row)
|
||||
|
||||
cats = []
|
||||
|
||||
for cat in data['Category']:
|
||||
aoi_id = cat['_id']
|
||||
Name = cat['Name']
|
||||
Parent = cat['Parent']
|
||||
row = [aoi_id, Name, Parent]
|
||||
cats.append(row)
|
||||
|
||||
log_to_database('LZ_attractions_io_categories', cats)
|
||||
#log_to_database('LZ_attractions_io_poi', rows)
|
||||
#!/usr/bin/env python3
|
||||
"""Import POI and category data from the attractions.io records bundle."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg2
|
||||
import requests
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
API_BASE = 'https://api.attractions.io/v1'
|
||||
API_KEY = '72f0ea9e-d196-508a-bee8-cce62c3228c7' # Knoebels app id (BuildConfig.API_KEY)
|
||||
APP_VERSION = '1.3.2' # bump if a newer xapk reveals drift
|
||||
APP_BUILD = 70
|
||||
NATIVE_VERSION = '1.0.129+6c346f2'
|
||||
|
||||
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': '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',
|
||||
}
|
||||
|
||||
CATEGORY_COLUMNS = ('_id', 'name', 'parent')
|
||||
|
||||
POI_COLUMNS = (
|
||||
'_id', 'name', 'abbreviated_name', 'summary', 'keywords', 'default_image',
|
||||
'location', 'featured', 'wayfinding_enabled', 'visible_on_map', 'category',
|
||||
'parent', 'menu_url', 'minimum_height_requirement',
|
||||
'minimum_unaccompanied_height_requirement', 'maximum_height_requirement',
|
||||
'minimum_age_requirement', 'minimum_unaccompanied_age_requirement',
|
||||
'maximum_age_requirement', 'restriction_summary',
|
||||
)
|
||||
|
||||
|
||||
def _occasio_headers(installation_token=None):
|
||||
auth = f'Attractions-Io api-key="{API_KEY}"'
|
||||
if installation_token:
|
||||
auth += f', installation-token="{installation_token}"'
|
||||
return {
|
||||
'Authorization': auth,
|
||||
'X-Idempotency-Key': str(uuid.uuid4()),
|
||||
'Date': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
'Occasio-Platform': 'Android',
|
||||
'Occasio-Platform-Version': '14',
|
||||
'Occasio-App-Version': APP_VERSION,
|
||||
'Occasio-App-Build': str(APP_BUILD),
|
||||
'Occasio-Native-Version': NATIVE_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def fetch_records_bundle():
|
||||
"""Two-step Occasio data fetch: register an installation, then pull the data zip."""
|
||||
install_resp = requests.post(
|
||||
f'{API_BASE}/installation',
|
||||
headers=_occasio_headers(),
|
||||
files={
|
||||
'device_identifier': (None, '123'),
|
||||
'user_identifier': (None, str(uuid.uuid4())),
|
||||
'app_build': (None, str(APP_BUILD)),
|
||||
'app_version': (None, APP_VERSION),
|
||||
},
|
||||
)
|
||||
install_resp.raise_for_status()
|
||||
token = install_resp.json()['token']
|
||||
|
||||
data_resp = requests.get(
|
||||
f'{API_BASE}/data',
|
||||
headers=_occasio_headers(installation_token=token),
|
||||
)
|
||||
data_resp.raise_for_status()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data_resp.content)) as zf:
|
||||
with zf.open('records.json') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _location_to_point(loc):
|
||||
# Location arrives as 'lat,lon'; Postgres point input wants '(x,y)'.
|
||||
if not loc:
|
||||
return None
|
||||
return f'({loc})'
|
||||
|
||||
|
||||
def _category_row(cat):
|
||||
return (cat['_id'], cat['Name'], cat.get('Parent'))
|
||||
|
||||
|
||||
def _poi_row(poi):
|
||||
return (
|
||||
poi['_id'],
|
||||
poi.get('Name'),
|
||||
poi.get('AbbreviatedName'),
|
||||
poi.get('Summary'),
|
||||
poi.get('Keywords'),
|
||||
poi.get('DefaultImage'),
|
||||
_location_to_point(poi.get('Location')),
|
||||
poi.get('Featured'),
|
||||
poi.get('WayfindingEnabled'),
|
||||
poi.get('VisibleOnMap'),
|
||||
poi.get('Category'),
|
||||
poi.get('Parent'),
|
||||
poi.get('MenuURL'),
|
||||
poi.get('MinimumHeightRequirement'),
|
||||
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
|
||||
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