import argparse import csv import os import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import date, timedelta from typing import Dict, Iterable, Tuple import requests PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if PROJECT_ROOT not in sys.path: sys.path.insert(0, PROJECT_ROOT) from src.data_collection.city_registry import CITY_REGISTRY # noqa: E402 HOURLY_FIELDS = ( "temperature_2m", "relative_humidity_2m", "wind_speed_10m", "wind_direction_10m", "cloud_cover", "shortwave_radiation", "precipitation", "surface_pressure", ) def _city_output_path(output_dir: str, city_name: str) -> str: filename = f"{city_name.replace(' ', '_').lower()}_historical.csv" return os.path.join(output_dir, filename) def _fetch_city_history( city_name: str, city_info: Dict[str, object], output_dir: str, start_date: str, end_date: str, session: requests.Session, overwrite: bool = False, max_retries: int = 4, ) -> Tuple[str, str]: output_path = _city_output_path(output_dir, city_name) if os.path.exists(output_path) and not overwrite: return city_name, "skipped_existing" lat = city_info["lat"] lon = city_info["lon"] url = ( "https://archive-api.open-meteo.com/v1/archive" f"?latitude={lat}&longitude={lon}" f"&start_date={start_date}&end_date={end_date}" f"&hourly={','.join(HOURLY_FIELDS)}" "&timezone=auto" ) last_error = None payload = None for attempt in range(max_retries + 1): try: response = session.get(url, timeout=90) response.raise_for_status() payload = response.json() break except requests.HTTPError as exc: last_error = exc response = getattr(exc, "response", None) status_code = response.status_code if response is not None else None if status_code == 429 and attempt < max_retries: retry_after = 0 if response is not None: try: retry_after = int(response.headers.get("Retry-After") or "0") except Exception: retry_after = 0 sleep_sec = retry_after if retry_after > 0 else min(90, 5 * (attempt + 1)) time.sleep(sleep_sec) continue raise except requests.RequestException as exc: last_error = exc if attempt < max_retries: time.sleep(min(30, 3 * (attempt + 1))) continue raise if payload is None: raise RuntimeError(f"failed to fetch {city_name}: {last_error}") hourly = payload.get("hourly") or {} times = hourly.get("time") or [] if not times: raise RuntimeError(f"missing hourly data for {city_name}") fieldnames = ["time", *HOURLY_FIELDS] rows = [] for idx, ts in enumerate(times): row = {"time": ts} for field in HOURLY_FIELDS: values = hourly.get(field) or [] row[field] = values[idx] if idx < len(values) else None rows.append(row) os.makedirs(output_dir, exist_ok=True) with open(output_path, "w", encoding="utf-8", newline="") as fh: writer = csv.DictWriter(fh, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) return city_name, f"downloaded:{len(rows)}" def _iter_target_cities(selected: Iterable[str]) -> Iterable[Tuple[str, Dict[str, object]]]: if not selected: for city_name, city_info in sorted(CITY_REGISTRY.items()): yield city_name, city_info return normalized = {str(city).strip().lower() for city in selected if str(city).strip()} for city_name, city_info in sorted(CITY_REGISTRY.items()): if city_name in normalized: yield city_name, city_info def main(): parser = argparse.ArgumentParser(description="Backfill historical Open-Meteo CSVs for PolyWeather cities.") parser.add_argument( "--output-dir", default=os.path.join(PROJECT_ROOT, "data", "historical"), help="Output directory for per-city historical CSV files.", ) parser.add_argument( "--start-date", default="2023-01-01", help="Archive start date in YYYY-MM-DD.", ) parser.add_argument( "--end-date", default=(date.today() - timedelta(days=1)).strftime("%Y-%m-%d"), help="Archive end date in YYYY-MM-DD.", ) parser.add_argument( "--cities", nargs="*", default=[], help="Optional subset of city registry keys to backfill.", ) parser.add_argument( "--overwrite", action="store_true", help="Re-download existing CSV files instead of skipping them.", ) parser.add_argument( "--workers", type=int, default=4, help="Maximum concurrent download workers.", ) args = parser.parse_args() cities = list(_iter_target_cities(args.cities)) if not cities: raise SystemExit("no matching cities found") session = requests.Session() session.headers.update({"User-Agent": "PolyWeather Historical Backfill/1.0"}) results = [] with ThreadPoolExecutor(max_workers=max(1, args.workers)) as executor: futures = { executor.submit( _fetch_city_history, city_name, city_info, args.output_dir, args.start_date, args.end_date, session, args.overwrite, ): city_name for city_name, city_info in cities } for future in as_completed(futures): city_name = futures[future] try: _, status = future.result() print(f"{city_name}: {status}") results.append((city_name, status)) except Exception as exc: print(f"{city_name}: error:{exc}") results.append((city_name, f"error:{exc}")) time.sleep(0.1) downloaded = sum(1 for _, status in results if status.startswith("downloaded:")) skipped = sum(1 for _, status in results if status == "skipped_existing") failed = [city for city, status in results if status.startswith("error:")] print( "summary downloaded={downloaded} skipped={skipped} failed={failed}".format( downloaded=downloaded, skipped=skipped, failed=len(failed), ) ) if failed: print("failed_cities=" + ",".join(sorted(failed))) if __name__ == "__main__": main()