Spaces:
Running
Running
Download intel/ingest/weather.py from Success-dev/venthrive-api: direct link, hf CLI and curl.
- Browser
- Download file 7.9 kB
-
https://huggingface.co/spaces/Success-dev/venthrive-api/resolve/main/intel/ingest/weather.py
- Command line
-
hf download hf://spaces/Success-dev/venthrive-api/intel/ingest/weather.py
-
curl -L -o weather.py https://huggingface.co/spaces/Success-dev/venthrive-api/resolve/main/intel/ingest/weather.py
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)) | |