venthrive-api / intel /ingest /weather.py
GitHub Actions
Sync from GitHub · 2026-10-21 11:53 UTC
b7c46f4
Raw History Blame Contribute Delete
7.9 kB
"""
Weather history and forecasts from Open-Meteo, at state centres (Phase 1's
native level — LGA-level history is the same code with level='lga', paced over
more days of quota).
Quota is the real constraint, not code: Open-Meteo counts a request covering
more than 2 weeks or more than 10 variables as several calls (weight ≈ days/14 ×
max(1, vars/10)), and the free tier allows 600/min, 5,000/hour, 10,000/day. 20
years × 37 states is ~19k weighted calls, so history is a *resumable backfill*:
each run fetches whole missing years, oldest first, until it hits its budget,
and the next run carries on where it stopped. A 429 ends the run cleanly as a
partial success rather than an error.
The free tier is for non-commercial use — fine for local Phase 1 development; a
paid plan (or ECMWF open data direct) is needed before this runs for a live
product (design doc, "Conflicts").
"""
import math
import sqlite3
import time
from datetime import date, timedelta
from typing import Callable
import httpx
from intel.db import now_iso
from intel.freshness import record_attempt
from intel.handoff import DATA_STREAM, INTERFACE, produce
from intel.registry import WEATHER_VARIABLES
ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
VARIABLES = [v for v, _ in WEATHER_VARIABLES]
DEFAULT_HISTORY_START = date(2006, 1, 1)
# The reanalysis archive trails real time by about 5 days.
ARCHIVE_LAG_DAYS = 6
PER_MINUTE_BUDGET = 500 # below Open-Meteo's 600/min, leaving headroom
DEFAULT_RUN_BUDGET = 4500 # below the 5,000/hour limit
Fetch = Callable[[str, dict], dict]
class QuotaExhausted(Exception):
pass
def call_weight(days: int, n_vars: int = len(VARIABLES)) -> float:
return max(1.0, days / 14) * max(1.0, n_vars / 10)
def http_fetch(url: str, params: dict) -> dict:
r = httpx.get(url, params=params, timeout=60.0)
if r.status_code == 429:
raise QuotaExhausted(r.text[:200])
r.raise_for_status()
return r.json()
def _points(con: sqlite3.Connection, level: str) -> list[sqlite3.Row]:
return con.execute("select id, lat, lon from locations where level = ? order by id", (level,)).fetchall()
def _store(con: sqlite3.Connection, location_id: str, payload: dict, is_forecast: bool, issued_at: str) -> int:
daily = payload["daily"]
now = now_iso()
rows = []
for var in VARIABLES:
for day, value in zip(daily["time"], daily.get(var, [])):
if value is not None:
rows.append((f"weather.{var}", location_id, day, int(is_forecast), issued_at, float(value), now))
con.executemany(
"""insert into observations (stream_id, location_id, period_start, is_forecast, issued_at, value, ingested_at)
values (?, ?, ?, ?, ?, ?, ?)
on conflict (stream_id, location_id, period_start, is_forecast, issued_at)
do update set value = excluded.value, ingested_at = excluded.ingested_at""",
rows,
)
con.commit()
return len(rows)
def _missing_years(con: sqlite3.Connection, location_id: str, start: date, end: date) -> list[tuple[date, date]]:
"""Calendar-year spans (clipped to [start, end]) still missing rows for the
first variable. A year that's complete from its first day up to some date
(the current year, typically) only resumes from the day after; any other
gap refetches the whole year — simpler than tracking holes, and rare."""
have = {r[0]: (r[1], r[2]) for r in con.execute(
"""select substr(period_start, 1, 4) y, count(*), max(period_start) from observations
where stream_id = ? and location_id = ? and is_forecast = 0 group by y""",
(f"weather.{VARIABLES[0]}", location_id),
)}
spans = []
for y in range(start.year, end.year + 1):
a, b = max(start, date(y, 1, 1)), min(end, date(y, 12, 31))
count, last = have.get(str(y), (0, None))
if count >= (b - a).days + 1:
continue
if last and count == (date.fromisoformat(last) - a).days + 1:
a = date.fromisoformat(last) + timedelta(days=1)
spans.append((a, b))
return spans
def backfill_history(con: sqlite3.Connection, level: str = "state", start: date = DEFAULT_HISTORY_START,
budget: float = DEFAULT_RUN_BUDGET, fetch: Fetch = http_fetch,
sleep: Callable[[float], None] = time.sleep, run_id: str = "manual") -> dict:
end = date.today() - timedelta(days=ARCHIVE_LAG_DAYS)
todo = [(p, a, b) for p in _points(con, level) for a, b in _missing_years(con, p["id"], start, end)]
todo.sort(key=lambda t: (t[1], t[0]["id"])) # oldest years first, across all points
spent = minute_spent = 0.0
minute_started = time.monotonic()
fetched = rows = 0
stopped = None
try:
for p, a, b in todo:
w = call_weight((b - a).days + 1)
if spent + w > budget:
stopped = "run budget reached"
break
if minute_spent + w > PER_MINUTE_BUDGET:
sleep(max(0.0, 60 - (time.monotonic() - minute_started)))
minute_spent, minute_started = 0.0, time.monotonic()
payload = fetch(ARCHIVE_URL, {
"latitude": p["lat"], "longitude": p["lon"], "start_date": a.isoformat(), "end_date": b.isoformat(),
"daily": ",".join(VARIABLES), "timezone": "Africa/Lagos",
})
rows += _store(con, p["id"], payload, is_forecast=False, issued_at="")
spent += w
minute_spent += w
fetched += 1
except QuotaExhausted as e:
stopped = f"Open-Meteo quota: {e}"
except Exception as e:
record_attempt(con, "open_meteo_archive", ok=False, error=str(e))
raise
newest = con.execute(
"select max(period_start) from observations where stream_id like 'weather.%' and is_forecast = 0"
).fetchone()[0]
remaining = len(todo) - fetched
if fetched or not todo:
record_attempt(con, "open_meteo_archive", ok=True, latest_data_period=newest)
else:
record_attempt(con, "open_meteo_archive", ok=False, error=stopped)
summary = {"level": level, "requests": fetched, "weighted_calls": round(spent, 1), "rows": rows,
"year_chunks_remaining": remaining, "stopped": stopped, "newest_day": newest}
if fetched:
produce(con, run_id, DATA_STREAM, INTERFACE, {"kind": "weather_history", **summary})
return summary
def fetch_forecast(con: sqlite3.Connection, level: str = "state", days: int = 16,
fetch: Fetch = http_fetch, run_id: str = "manual") -> dict:
issued = date.today().isoformat()
rows = n = 0
try:
for p in _points(con, level):
payload = fetch(FORECAST_URL, {
"latitude": p["lat"], "longitude": p["lon"], "forecast_days": days,
"daily": ",".join(VARIABLES), "timezone": "Africa/Lagos",
})
rows += _store(con, p["id"], payload, is_forecast=True, issued_at=issued)
n += 1
except Exception as e:
record_attempt(con, "open_meteo_forecast", ok=False, error=str(e))
raise
record_attempt(con, "open_meteo_forecast", ok=True, latest_data_period=issued)
summary = {"level": level, "points": n, "rows": rows, "issued_at": issued,
"weighted_calls": round(n * call_weight(days), 1)}
produce(con, run_id, DATA_STREAM, INTERFACE, {"kind": "weather_forecast", **summary})
return summary
def estimate_backfill_calls(con: sqlite3.Connection, level: str = "state", start: date = DEFAULT_HISTORY_START) -> float:
end = date.today() - timedelta(days=ARCHIVE_LAG_DAYS)
return math.fsum(call_weight((b - a).days + 1)
for p in _points(con, level) for a, b in _missing_years(con, p["id"], start, end))