diff --git a/scripts/check_open_meteo_cache.py b/scripts/check_open_meteo_cache.py new file mode 100644 index 00000000..508f3fa2 --- /dev/null +++ b/scripts/check_open_meteo_cache.py @@ -0,0 +1,164 @@ +"""Safely inspect local Open-Meteo cache state. + +This diagnostic is intentionally cache-first: + +- It does not call Open-Meteo. +- It does not hard-code any auth token. +- Optional backend API probing requires ``--api-detail`` and reads the bearer + token from ``POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN``. + +Examples: + python scripts/check_open_meteo_cache.py --city "sao paulo" + python scripts/check_open_meteo_cache.py --city ankara --api-detail +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.data_collection.city_registry import CITY_REGISTRY # noqa: E402 +from src.data_collection.weather_sources import WeatherDataCollector # noqa: E402 +from src.utils.config_loader import load_config # noqa: E402 + + +def _cache_age(entry: dict[str, Any]) -> str: + try: + age = int(time.time() - float(entry.get("t") or 0)) + except Exception: + return "unknown" + return f"{age}s" + + +def _city_meta(city: str) -> dict[str, Any]: + key = city.strip().lower() + meta = CITY_REGISTRY.get(key) + if not meta: + raise SystemExit(f"Unknown city {city!r}. Use a key from CITY_REGISTRY.") + return meta + + +def _coord_prefix(meta: dict[str, Any]) -> str: + return f"{round(float(meta['lat']), 4)}:{round(float(meta['lon']), 4)}" + + +def _print_cooldown(collector: WeatherDataCollector) -> None: + refresh = getattr(collector, "_refresh_open_meteo_rate_limit_until", None) + until = float(refresh() if callable(refresh) else collector._open_meteo_rate_limit_until) + now = time.time() + if until > now: + print(f"Open-Meteo cooldown: active ({int(until - now)}s remaining)") + else: + print("Open-Meteo cooldown: inactive") + + +def _print_forecast_cache(collector: WeatherDataCollector, prefix: str) -> None: + print("\n[forecast cache]") + found = False + for key in sorted(collector._open_meteo_cache.keys()): + if not key.startswith(prefix): + continue + found = True + entry = collector._open_meteo_cache[key] + data = entry.get("data") if isinstance(entry, dict) else {} + hourly = data.get("hourly", {}) if isinstance(data, dict) else {} + daily = data.get("daily", {}) if isinstance(data, dict) else {} + print( + f"- key={key} age={_cache_age(entry)} " + f"hourly={len(hourly.get('time') or [])} daily={len(daily.get('time') or [])}" + ) + if not found: + print("- no matching forecast cache") + + +def _print_multi_model_cache(collector: WeatherDataCollector, prefix: str) -> None: + print("\n[multi-model cache]") + found = False + for key in sorted(collector._multi_model_cache.keys()): + if not key.startswith(prefix): + continue + found = True + entry = collector._multi_model_cache[key] + data = entry.get("data") if isinstance(entry, dict) else {} + forecasts = data.get("forecasts", {}) if isinstance(data, dict) else {} + dates = data.get("dates", []) if isinstance(data, dict) else [] + print( + f"- key={key} age={_cache_age(entry)} " + f"models={len(forecasts)} dates={len(dates)}" + ) + if not found: + print("- no matching multi-model cache") + + +def _print_ensemble_cache(collector: WeatherDataCollector, prefix: str) -> None: + print("\n[ensemble cache]") + found = False + for key in sorted(collector._ensemble_cache.keys()): + if not key.startswith(prefix): + continue + found = True + entry = collector._ensemble_cache[key] + data = entry.get("data") if isinstance(entry, dict) else {} + print( + f"- key={key} age={_cache_age(entry)} " + f"members={data.get('members') if isinstance(data, dict) else None}" + ) + if not found: + print("- no matching ensemble cache") + + +def _fetch_api_detail(city: str, base_url: str) -> None: + token = os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN", "").strip() + if not token: + raise SystemExit( + "--api-detail requires POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN in the environment" + ) + quoted_city = urllib.parse.quote(city) + url = f"{base_url.rstrip('/')}/api/city/{quoted_city}/detail?force_refresh=false" + request = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.loads(response.read()) + + hourly = (payload.get("timeseries") or {}).get("hourly") or {} + models = payload.get("models") or {} + deb = payload.get("deb") or {} + print("\n[backend detail]") + print(f"- status=ok hourly={len(hourly.get('times') or [])} models={len(models)}") + print(f"- deb_prediction={deb.get('prediction')}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Inspect local Open-Meteo caches safely.") + parser.add_argument("--city", default="sao paulo", help="CITY_REGISTRY key to inspect") + parser.add_argument("--api-detail", action="store_true", help="Also query local backend detail API") + parser.add_argument("--base-url", default="http://localhost:8000", help="Backend base URL") + args = parser.parse_args() + + city = args.city.strip().lower() + meta = _city_meta(city) + prefix = _coord_prefix(meta) + print(f"city={city} coord_prefix={prefix}") + + collector = WeatherDataCollector(load_config()) + _print_cooldown(collector) + _print_forecast_cache(collector, prefix) + _print_multi_model_cache(collector, prefix) + _print_ensemble_cache(collector, prefix) + + if args.api_detail: + _fetch_api_detail(city, args.base_url) + + +if __name__ == "__main__": + main()