Spaces:
Running
Running
File size: 7,900 Bytes
b7c46f4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | """
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))
|