Compare commits

..
11 Commits
Author SHA1 Message Date
wesandClaude Opus 4.8 94a91e29ad knb_stats: validate PC interpretations + rank-3 reconstruction + writeup PC sections
pca_svd: exclude torn-down powersurge from the PCA, trim attractions.io
category names (trailing-space gotcha), time-side validation of PC1-PC3
interpretations, per-ride R^2 of the rank-3 reconstruction, blog-ready
PC2xPC3 scatter. writeup: PC1/PC2/PC3 narrative sections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:22:37 -04:00
wesandClaude Opus 4.8 5c09528df7 Add Open-Meteo hourly weather pipeline + queue/weather view
Backfills knoebels."LZ_open_meteo_hourly" from 2024-05-15 (start of queue
collection) and keeps it current. Imperial units; timestamps stored as naive
America/New_York to line up with the rest of the knoebels schema.

- weather_logger.py: --backfill uses the ERA5 Archive API; default mode does a
  self-healing recent sync via the Forecast API (past_days=7, upsert on conflict)
- docker-compose.yml: add `weather` oneshot service (also align scraper to its
  live `oneshot` profile)
- systemd/knb-weather.{service,timer}: nox user timer, every 6h (linger enabled)
- queries/dm_knb_queue_weather.sql: dm_knb_queue_weather view joining the full
  queue-time series to hourly weather (hour-bucketed), with a WMO code decode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:38:01 -04:00
wesandClaude Opus 4.7 f7e0042ef8 Add "How Busy?" tab: 4 learned tiers + dual logistic regression
New dashboard tab that rates current park crowding:

- Tiers: k-means on park-wide mean wait yields 4 ordinal levels
  ("Walk right on" / "Pleasant" / "Packed" / "What were you
  thinking?!"), with the rare 0.6% zoo tail isolated as its own
  tier rather than buried in a quartile.
- Board-reading model: multinomial LR on per-ride waits -> tier.
  Drives the live gauge (current avg wait + predicted tier +
  confidence + class-probability bar). Near-perfect by construction
  -- honest framing baked into the model card.
- Calendar-forecast model: LR on hour/dow/month/weekend -> tier,
  evaluated with GroupKFold by date to avoid same-day leakage.
  Drives the "expected vs actual" verdict for the current moment.
- Bellwether rides bar (top tier coefficients) and a confusion-
  matrix expander tell the honest story: forecast barely beats
  baseline on everyday tiers, but pins the zoo tier nearly 100% --
  because Knoebels' worst crowds are locked to specific October
  festival weekends. "The chaos has a schedule."

Implementation notes: trained models cached via st.cache_resource
(6h), live snapshot fetched separately on a 5-min ttl. Context
features use a fixed column set so live inference always aligns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:38:23 -04:00
wesandClaude Opus 4.7 ad163cb996 Expose PC4 and make the PCA behavioral map configurable
- compute_pca now fits 4 components; PC4 added to PC_LABELS/PC_BLURB
  ("off-peak spread vs headliner pull", ~5% variance, framed as
  diminishing returns).
- "Read an axis" loadings explorer gains PC4.
- The behavioral-map scatter becomes interactive: pick any two
  components for the X/Y axes, and color by category tag, spatial
  area, or any component's loading (continuous RdBu, symmetric).
- Headline caption pinned to evr[:3] so "3 axes = 60%" stays true;
  the by-hour clock stays PC1-3 to preserve the three-axes story.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:54:52 -04:00
wesandClaude Opus 4.7 e65d1c796c Name the GPS k-means areas by their marquee ride
Replace the "Area N" fallbacks with derived names: Kiddieland
(lowest mean PC2) plus The Woodies / The Grand Midway / Flying
Turns Grove / Giant Wheel Corner, each keyed off the headline
ride that lands in the cluster so the labels survive re-clustering
as the wait history grows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:35:42 -04:00
wesandClaude Opus 4.7 53109bc7fe Add interactive 3D PCA views to dashboard Park Mind tab
Two spin/zoom Plotly scatter_3d sub-tabs in the PCA tab:
- Rides in PC1xPC2xPC3 (loadings), color by category / GPS
  k-means area / PC1 busyness. Adds GPS k-means areas to
  compute_pca(), auto-labeling the lowest-PC2 cluster Kiddieland.
- Moments in PC1xPC2xPC3 (per-snapshot scores), color by hour /
  day-of-week / weekend / month, sampled to 15k points if larger.

Pin numpy in requirements (now imported directly).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:53:55 -04:00
wesandClaude Opus 4.7 ade774f5aa Add Streamlit app + knb_stats analysis notebooks
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:06:19 -04:00
wesandClaude Opus 4.7 dd6ff18b04 Ignore APK/XAPK drift-check artifacts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 07:52:44 -04:00
wesandClaude Opus 4.7 c247e15a4e Fetch POI records from attractions.io API and upsert
Replace the local records.json read with a two-step Occasio fetch
(install + data zip), then upsert Items and Categories into the
knoebels schema with ON CONFLICT (_id) DO UPDATE so re-runs refresh
in place. Also brings DB credentials in line with attractions_api_logger.py
(env vars, default host 192.168.88.9), drops dead helpers, and uses a
single shared connection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 07:52:03 -04:00
wesandClaude Opus 4.7 7a92834c8b Share one DB connection per run + cleanups
Open one psycopg2 connection in main() and pass it through pull_data_and_log
and log_to_database. Previously each landing-zone insert opened its own
connection (3-4 handshakes per invocation); now everything runs in a single
session and the three LZ writes share one transaction.

Other cleanups:
- Replace bare `except:` blocks around attraction-field reads with .get()
- Drop the unused col_names computation in the old generic_query
- Move module-level driver code under `if __name__ == "__main__":`
- Parse park_opens / park_closes once instead of three times
- Name columns explicitly in the park-hours query (was SELECT *)
- Use `datestamp` (date column) for the today-freshness check rather
  than reading .date() off the open timestamp

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 06:36:31 -04:00
wes 10bdee8e1f Containerize scraper and externalize credentials
Move DB host/user/password, Discord webhook, and log path to env vars
so the script can be deployed via Docker compose. Discord notifications
are now skipped silently when DISCORD_WEBHOOK is unset (the old
hardcoded webhook is removed pending rotation). Adds Dockerfile,
docker-compose.yml, requirements.txt, .env.example, and .gitignore for
the cyrion deployment.
2026-05-06 23:20:12 -04:00
16 changed files with 3728 additions and 299 deletions
+16
View File
@@ -0,0 +1,16 @@
# Knoebels scraper environment configuration.
# Copy to .env on the deploy host and fill in real values.
# Never commit the real .env.
# PostgreSQL connection (phlegethon by default)
DB_HOST=192.168.88.9
DB_PORT=5432
DB_USER=kuhnobowls
DB_PASSWORD=
# Optional Discord webhook for notifications.
# Leave empty/unset to skip Discord calls entirely.
DISCORD_WEBHOOK=
# Log path inside the container. Mapped to the knb_logs named volume by compose.
LOG_PATH=/var/log/kuh-no-bowls/kuh-no-bowls.log
+9
View File
@@ -0,0 +1,9 @@
.env
__pycache__/
*.pyc
.venv/
venv/
historical_data/
*.log
*.xapk
*.apk
+6
View File
@@ -0,0 +1,6 @@
[theme]
primaryColor = "#2E8B57"
backgroundColor = "#1A1C18"
secondaryBackgroundColor = "#2D302A"
textColor = "#E6E6E6"
font = "sans serif"
+15
View File
@@ -0,0 +1,15 @@
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 . .
# Default to scraper, but can be overridden in docker-compose
ENTRYPOINT ["python", "-u", "attractions_api_logger.py"]
+891
View File
@@ -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' AprilOctober 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()
+185 -181
View File
@@ -1,181 +1,185 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from datetime import datetime, date, timedelta import json
import json, requests, psycopg2, logging, sys import logging
from psycopg2 import sql import os
import sys
ATTRACTIONS_IO_ENDPOINT = "https://live-data.attractions.io/72f0ea9e-d196-508a-bee8-cce62c3228c7.json" from datetime import date, datetime, timedelta
DISCORD_WEBHOOK = 'https://discord.com/api/webhooks/878651005015842827/o_KXXiPgor-DhZQ9vgL-MNOYb-RDmQCAbuYmgUpsoEOqu3XCfBdUM6rDmF1cMrZg12b0'
import psycopg2
db_params = { import requests
'dbname': 'gp0', from psycopg2 import sql
'user': 'kuhnobowls',
'password': 'saddog095', ATTRACTIONS_IO_ENDPOINT = "https://live-data.attractions.io/72f0ea9e-d196-508a-bee8-cce62c3228c7.json"
'host': '192.168.88.5', DISCORD_WEBHOOK = os.environ.get('DISCORD_WEBHOOK', '').strip()
'port': '5432', LOG_PATH = os.environ.get('LOG_PATH', '/var/log/kuh-no-bowls.log')
'options': '-c search_path=knoebels'
} DB_PASSWORD = os.environ.get('DB_PASSWORD')
if not DB_PASSWORD:
park_hours_query = """ sys.stderr.write("ERROR: DB_PASSWORD env var is required\n")
SELECT * sys.exit(1)
FROM knoebels."LZ_attractions_io_resort"
ORDER BY datestamp DESC db_params = {
LIMIT 1 'dbname': 'gp0',
""" 'user': os.environ.get('DB_USER', 'kuhnobowls'),
'password': DB_PASSWORD,
def send_to_discord(message): 'host': os.environ.get('DB_HOST', '192.168.88.9'),
# The payload to send to the webhook 'port': os.environ.get('DB_PORT', '5432'),
data = { 'options': '-c search_path=knoebels',
"content": message }
}
PARK_HOURS_QUERY = """
try: SELECT datestamp, _id, open, close, closed
# Send the POST request to the webhook URL FROM knoebels."LZ_attractions_io_resort"
response = requests.post(DISCORD_WEBHOOK, json=data) ORDER BY datestamp DESC
LIMIT 1
# Check if the request was successful """
if response.status_code == 204:
print("Message sent successfully.") API_TIME_FMT = "%Y-%m-%d %H:%M:%S"
else:
print(f"Failed to send message. Status code: {response.status_code}, Response: {response.text}")
except requests.exceptions.RequestException as e: def send_to_discord(message):
print(f"Error sending message: {e}") if not DISCORD_WEBHOOK:
return
def log_to_database(table_name, data): try:
with psycopg2.connect(**db_params) as conn: response = requests.post(DISCORD_WEBHOOK, json={"content": message})
with conn.cursor() as cursor: if response.status_code != 204:
columns = len(data[0]) logging.warning("Discord webhook returned %s: %s", response.status_code, response.text)
query = sql.SQL("INSERT INTO {table} VALUES ({values})").format( except requests.exceptions.RequestException as e:
table=sql.Identifier(table_name), logging.warning("Discord webhook error: %s", e)
values=sql.SQL(', ').join(sql.Placeholder() * columns)
)
def log_to_database(conn, table_name, data):
cursor.executemany(query, data) if not data:
conn.commit() return
query = sql.SQL("INSERT INTO {table} VALUES ({values})").format(
def generic_query(q): table=sql.Identifier(table_name),
ret = [] values=sql.SQL(', ').join(sql.Placeholder() * len(data[0])),
with psycopg2.connect(**db_params) as conn: )
with conn.cursor() as cursor: with conn.cursor() as cur:
cursor.execute(q) cur.executemany(query, data)
rows = cursor.fetchall() conn.commit()
col_names = [desc[0] for desc in cursor.description]
for row in rows: def fetch_attractions_payload():
ret.append(row) headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
return ret 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
def fetch_webpage(url): 'DNT': '1',
HEADERS = { 'Sec-GPC': '1',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', 'Connection': 'keep-alive',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Upgrade-Insecure-Requests': '1',
'Accept-Language': 'en-US,en;q=0.5', 'Sec-Fetch-Dest': 'document',
'DNT': '1', 'Sec-Fetch-Mode': 'navigate',
'Sec-GPC': '1', 'Sec-Fetch-Site': 'none',
'Connection': 'keep-alive', 'Sec-Fetch-User': '?1',
'Upgrade-Insecure-Requests': '1', }
'Sec-Fetch-Dest': 'document', response = requests.get(ATTRACTIONS_IO_ENDPOINT, headers=headers)
'Sec-Fetch-Mode': 'navigate', response.raise_for_status()
'Sec-Fetch-Site': 'none', return response.json()
'Sec-Fetch-User': '?1',
}
try: def _attraction_opening_times(raw):
response = requests.get(url, headers=HEADERS) if not raw:
return None, None
if response.status_code == 200: try:
return response.json() parsed = json.loads(raw)
else: except (json.JSONDecodeError, TypeError):
return f"Error: {response.status_code} - Unable to fetch webpage" return None, None
except Exception as e: return parsed.get('start'), parsed.get('end')
return f"Error: {str(e)} - Unable to fetch webpage"
def pull_data_and_log(log_park_hours): def pull_data_and_log(conn, log_park_hours):
json_data = fetch_webpage(ATTRACTIONS_IO_ENDPOINT) json_data = fetch_attractions_payload()
logging.debug('Parsing park hours...') logging.debug('Parsing park hours...')
try: try:
park_opens = json.loads(json_data['entities']['Resort']['records'][0]['OpeningTimes'])['start'] opening_raw = json_data['entities']['Resort']['records'][0]['OpeningTimes']
park_closes = json.loads(json_data['entities']['Resort']['records'][0]['OpeningTimes'])['end'] resort_id = json_data['entities']['Resort']['records'][0]['_id']
except KeyError as e: opening_times = json.loads(opening_raw)
logging.error('Unable to pull park hours. Is the park open today?') park_opens = opening_times['start']
return park_closes = opening_times['end']
except (KeyError, IndexError, json.JSONDecodeError):
logging.debug('Checking that park hours from API are actually occuring today...') logging.error('Unable to pull park hours. Is the park open today?')
if not datetime.today().date() == datetime.strptime(park_opens, "%Y-%m-%d %H:%M:%S").date(): return
logging.error('API not returning park hours for today yet. Exiting...')
return opens_dt = datetime.strptime(park_opens, API_TIME_FMT)
closes_dt = datetime.strptime(park_closes, API_TIME_FMT)
resort_data = [[datetime.now(), json_data['entities']['Resort']['records'][0]['_id'], park_opens, park_closes]]
if log_park_hours: if datetime.today().date() != opens_dt.date():
logging.info('Inserting park hours into LZ_attractions_io_resort...') logging.error('API not returning park hours for today yet. Exiting...')
log_to_database('LZ_attractions_io_resort', resort_data) return
if not (datetime.strptime(park_opens, "%Y-%m-%d %H:%M:%S") - timedelta(minutes=5)) <= datetime.now() <= (datetime.strptime(park_closes, "%Y-%m-%d %H:%M:%S") + timedelta(minutes=5)):
logging.info('Park not open now. Exiting...') if log_park_hours:
return logging.info('Inserting park hours into LZ_attractions_io_resort...')
log_to_database(conn, 'LZ_attractions_io_resort',
attraction_data = [] [[datetime.now(), resort_id, park_opens, park_closes]])
if not (opens_dt - timedelta(minutes=5)) <= datetime.now() <= (closes_dt + timedelta(minutes=5)):
for attraction in json_data['entities']['Item']['records']: logging.info('Park not open now. Exiting...')
try: return
isOperational = attraction['IsOperational']
except: attraction_data = []
isOperational = None for attraction in json_data['entities']['Item']['records']:
try: start, end = _attraction_opening_times(attraction.get('OpeningTimes'))
QueueTime = attraction['QueueTime'] attraction_data.append([
except: attraction['_id'],
QueueTime = None attraction.get('IsOperational'),
try: attraction.get('QueueTime'),
QueueStatusMessage = attraction['QueueStatusMessage'] attraction.get('QueueStatusMessage'),
except: attraction.get('IsOpen'),
QueueStatusMessage = None start,
try: end,
IsOpen = attraction['IsOpen'] datetime.now(),
except: ])
IsOpen = None
try: queueline_data = [
OpeningTimes = json.loads(attraction['OpeningTimes']) [datetime.now(), queue['_id'], queue['QueueTime']]
start = OpeningTimes['start'] for queue in json_data['entities']['QueueLine']['records']
end = OpeningTimes['end'] ]
except:
start = None logging.debug('Inserting rows into LZ_attractions_io...')
end = None log_to_database(conn, 'LZ_attractions_io', attraction_data)
row = [attraction['_id'], isOperational, QueueTime, QueueStatusMessage, IsOpen, start, end, datetime.now()] logging.debug('Inserting rows into LZ_attractions_io_queuetimes...')
attraction_data.append(row) log_to_database(conn, 'LZ_attractions_io_queuetimes', queueline_data)
queueline_data = []
def main():
for queue in json_data['entities']['QueueLine']['records']: logging.basicConfig(
row = [datetime.now(), queue['_id'], queue['QueueTime']] format='%(asctime)s %(levelname)s %(message)s',
queueline_data.append(row) level=logging.DEBUG,
filename=LOG_PATH,
logging.debug('Inserting rows into LZ_attractions_io...') filemode="a",
log_to_database('LZ_attractions_io', attraction_data) )
logging.info('-' * 80)
logging.debug('Inserting rows into LZ_attractions_io_queuetimes...') logging.info('Fetching most recent park hours entry from DB...')
log_to_database('LZ_attractions_io_queuetimes', queueline_data)
with psycopg2.connect(**db_params) as conn:
with conn.cursor() as cur:
logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', level=logging.DEBUG, filename="/home/hoid/git_repos/kuh-no-bowls/knb.log",filemode="a") cur.execute(PARK_HOURS_QUERY)
row = cur.fetchone()
logging.info('--------------------------------------------------------------------------------')
logging.info('Fetching most recent park hours entry from DB...') if not row:
logging.info('No park hours rows in DB — fetching from API')
park_hours_pull = generic_query(park_hours_query)[0] pull_data_and_log(conn, log_park_hours=True)
logging.debug('%s - %s', park_hours_pull[2], park_hours_pull[3]) return
if park_hours_pull[2].date() == date.today(): datestamp, _id, open_ts, close_ts, _closed = row
logging.info('Park hours for today already in DB') logging.debug('%s - %s', open_ts, close_ts)
park_opens = park_hours_pull[2]
park_closes = park_hours_pull[3] if datestamp != date.today():
if (park_opens - timedelta(minutes=5)) <= datetime.now() <= (park_closes + timedelta(minutes=5)): logging.info('Park hours for today not in DB')
logging.info('Park is open! Pulling data from API...') pull_data_and_log(conn, log_park_hours=True)
pull_data_and_log(False) return
else:
logging.info('Park not currently open. Exiting...') logging.info('Park hours for today already in DB')
else: if (open_ts - timedelta(minutes=5)) <= datetime.now() <= (close_ts + timedelta(minutes=5)):
logging.info('Park hours for today not in DB') logging.info('Park is open! Pulling data from API...')
pull_data_and_log(True) pull_data_and_log(conn, log_park_hours=False)
else:
logging.info('Park not currently open. Exiting...')
if __name__ == '__main__':
main()
+59
View File
@@ -0,0 +1,59 @@
services:
scraper:
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
View File
@@ -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()
+8
View File
@@ -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
+61
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
requests
psycopg2-binary
streamlit
pandas
numpy
plotly
python-dotenv
scikit-learn
+14
View File
@@ -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
+12
View File
@@ -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
+193
View File
@@ -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()