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))