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>
194 lines
6.3 KiB
Python
194 lines
6.3 KiB
Python
#!/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()
|