File size: 5,994 Bytes
f2046b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Fetches real, physics-based short-to-medium-range forecast data from
Open-Meteo's dedicated ECMWF endpoint -- built specifically for genuine
flood early-warning lead time, which (per the recent design discussion)
structurally requires a real precipitation forecast, not just discharge
history extrapolated forward.

CONFIRMED real API details (not guessed): base endpoint
https://api.open-meteo.com/v1/ecmwf, HTTP GET, no authentication, CC BY
4.0 licence, up to 10 real forecast days. Also confirmed to exist (not
yet used here): an archived-individual-model-run-by-init-time feature
explicitly described by Open-Meteo as built for bias-correction
pipelines without look-ahead leakage -- exactly what a later step would
need to calibrate this forecast against our own real climatology (see
climate_climatology.py). The exact endpoint for that archive wasn't
directly confirmed in this pass -- flagged for a follow-up check, not
guessed at here.

Usage:
    python -m scripts.fetch_ecmwf_forecast --check
    python -m scripts.fetch_ecmwf_forecast --stations-path datasets/station_elevations.csv
"""
import argparse
from pathlib import Path

import pandas as pd
import requests

BASE_URL = "https://api.open-meteo.com/v1/ecmwf"

# Daily aggregates, matching this project's existing daily-resolution
# pipeline convention (ERA5 climatology etc.) rather than pulling hourly
# data that would need its own separate aggregation step.
DAILY_VARIABLES = [
    "precipitation_sum", "temperature_2m_max", "temperature_2m_min",
    "wind_speed_10m_max", "shortwave_radiation_sum", "et0_fao_evapotranspiration",
]


def check_access() -> bool:
    """Single real request against a real coordinate in the study area
    (matches H602021010's real location) to confirm the endpoint and
    variable names actually work before looping over every station."""
    params = {
        "latitude": 48.748705218, "longitude": 0.58156195,
        "daily": ",".join(DAILY_VARIABLES), "forecast_days": 5,
        "timezone": "Europe/Paris",
    }
    print(f"Testing {BASE_URL} with a real study-area coordinate...")
    try:
        resp = requests.get(BASE_URL, params=params, timeout=30)
        resp.raise_for_status()
        data = resp.json()
    except Exception as e:
        print(f"FAILED: {e}")
        return False

    if "daily" not in data:
        print(f"FAILED: response has no 'daily' key. Full response: {data}")
        return False

    print("OK -- real forecast returned:")
    for var in DAILY_VARIABLES:
        if var in data["daily"]:
            print(f"  {var}: {data['daily'][var][:3]}... ({len(data['daily'][var])} real day(s))")
        else:
            print(f"  WARNING: '{var}' not found in response -- variable name may be wrong. "
                  f"Real keys returned: {list(data['daily'].keys())}")
    return True


def fetch_forecast(lat: float, lon: float, forecast_days: int = 10, max_retries: int = 3) -> pd.DataFrame:
    """
    Retries on read timeout -- confirmed as a real, not hypothetical,
    intermittent failure mode against the live API: 2 of 27 real
    stations hit a 30s read timeout on an otherwise-clean run, while
    the other 25 succeeded immediately. A short exponential backoff
    handles this automatically rather than requiring a manual re-run
    for whichever stations happen to be unlucky that run.
    """
    import time

    params = {
        "latitude": lat, "longitude": lon,
        "daily": ",".join(DAILY_VARIABLES), "forecast_days": forecast_days,
        "timezone": "Europe/Paris",
    }
    last_error = None
    for attempt in range(max_retries):
        try:
            resp = requests.get(BASE_URL, params=params, timeout=30)
            resp.raise_for_status()
            data = resp.json()
            break
        except requests.exceptions.RequestException as e:
            last_error = e
            if attempt < max_retries - 1:
                wait = 2 ** attempt  # 1s, 2s, 4s
                print(f"    attempt {attempt + 1}/{max_retries} failed ({e}), retrying in {wait}s...")
                time.sleep(wait)
    else:
        raise last_error

    if "daily" not in data:
        raise ValueError(f"No 'daily' data in response for ({lat}, {lon}): {data}")

    df = pd.DataFrame(data["daily"])
    df["latitude"] = lat
    df["longitude"] = lon
    return df


def main() -> None:
    parser = argparse.ArgumentParser(description="Fetch real ECMWF forecast data from Open-Meteo")
    parser.add_argument("--check", action="store_true")
    parser.add_argument("--stations-path", type=Path, default=Path("datasets/station_elevations.csv"))
    parser.add_argument("--forecast-days", type=int, default=10)
    parser.add_argument("--output", type=Path, default=Path("datasets/ecmwf_forecast.csv"))
    args = parser.parse_args()

    if args.check:
        check_access()
        return

    stations = pd.read_csv(args.stations_path)
    lat_col = "latitude" if "latitude" in stations.columns else "lat"
    lon_col = "longitude" if "longitude" in stations.columns else "lon"

    all_dfs = []
    for _, row in stations.iterrows():
        code = row.get("station_code", f"{row[lat_col]:.3f}_{row[lon_col]:.3f}")
        print(f"Fetching real forecast for {code}...")
        try:
            df = fetch_forecast(row[lat_col], row[lon_col], args.forecast_days)
            df["station_code"] = code
            all_dfs.append(df)
        except Exception as e:
            print(f"  FAILED for {code}: {e}")

    if not all_dfs:
        print("No real forecast data fetched at all -- check connectivity, or run --check first.")
        return

    combined = pd.concat(all_dfs, ignore_index=True)
    args.output.parent.mkdir(parents=True, exist_ok=True)
    combined.to_csv(args.output, index=False)
    print(f"\nSaved real forecast data for {combined['station_code'].nunique()} station(s), "
          f"{args.forecast_days} real day(s) each, to {args.output}")


if __name__ == "__main__":
    main()