diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 8b2a52d..28d02e2 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -426,7 +426,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Add weather transition rules. - [x] Add weather replication. - [x] Add real-world weather provider adapter for Ground Zero by latitude/longitude. Added a tile-driven Open-Meteo adapter subsystem that can request weather for any active tile by center latitude/longitude, map provider weather codes into Agrarian weather states, apply current temperature and daily low/high to `AAgrarianGameState`, and generate a source-backed tile weather manifest so future real tiles become weather-eligible automatically. -- [ ] Use Open-Meteo as the first global MVP weather source. +- [x] Use Open-Meteo as the first global MVP weather source. Added `Data/Weather/open_meteo_mvp_source.json` as the provider contract, documented the global tile lookup rule, and added `Scripts/verify_open_meteo_mvp_source.py` to validate the static contract plus live Open-Meteo responses for every source-backed tile in the generated weather manifest. - [ ] Add NOAA/NWS fallback or enrichment for US tiles where useful. - [ ] Cache real-weather snapshots server-side so clients never call public weather APIs directly. - [ ] Map real weather inputs into Agrarian weather states: temperature, precipitation, wind, cloud cover, humidity, pressure, visibility, and weather code. diff --git a/Data/Weather/open_meteo_mvp_source.json b/Data/Weather/open_meteo_mvp_source.json new file mode 100644 index 0000000..bc0c685 --- /dev/null +++ b/Data/Weather/open_meteo_mvp_source.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "provider_id": "open-meteo", + "provider_name": "Open-Meteo Forecast API", + "provider_docs_url": "https://open-meteo.com/en/docs", + "forecast_endpoint": "https://api.open-meteo.com/v1/forecast", + "api_key_required": false, + "tile_lookup_rule": "Use each source-backed tile center latitude/longitude from Data/Tiles/tile_weather_manifest.json.", + "request_parameters": { + "current": [ + "temperature_2m", + "relative_humidity_2m", + "precipitation", + "rain", + "showers", + "snowfall", + "weather_code", + "cloud_cover", + "pressure_msl", + "wind_speed_10m" + ], + "daily": [ + "temperature_2m_max", + "temperature_2m_min", + "weather_code", + "precipitation_sum", + "wind_speed_10m_max" + ], + "forecast_days": 1, + "timezone": "auto" + }, + "agrarian_mapping": { + "temperature": "current.temperature_2m feeds RegionalObservedTemperatureC; daily min/max feed RegionalDailyLowTemperatureC and RegionalDailyHighTemperatureC.", + "weather_state": "weather_code, precipitation, and wind speed map into Clear, Rain, ColdWind, or Storm.", + "source_tracking": "provider id and provider current timestamp are stored in the game-state weather source string until full persistence metadata is implemented." + } +} diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 6f239cf..45ec672 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -157,6 +157,14 @@ source-backed, generated, validated, packaged, or published tile with center coordinates, while placeholder/unknown tiles are skipped. Future source-backed tiles therefore become weather-eligible when their registry entries are added. +Open-Meteo is the first global MVP weather source. The provider contract is +stored in `Data/Weather/open_meteo_mvp_source.json`, including the forecast +endpoint, requested current/daily variables, tile lookup rule, and Agrarian +mapping notes. `Scripts/verify_open_meteo_mvp_source.py` validates the static +contract and can perform a live Open-Meteo request for every source-backed tile +in `Data/Tiles/tile_weather_manifest.json`. This keeps the provider global for +all future real tiles instead of adding one-off Ground Zero weather code. + ## Terrain And Tile Delivery ### MVP Tile diff --git a/Scripts/verify_open_meteo_mvp_source.py b/Scripts/verify_open_meteo_mvp_source.py new file mode 100644 index 0000000..44a1f2d --- /dev/null +++ b/Scripts/verify_open_meteo_mvp_source.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Verify Open-Meteo is the global MVP weather source for source-backed tiles.""" + +from __future__ import annotations + +import json +import os +import urllib.parse +import urllib.request +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PROVIDER_CONTRACT = ROOT / "Data" / "Weather" / "open_meteo_mvp_source.json" +TILE_MANIFEST = ROOT / "Data" / "Tiles" / "tile_weather_manifest.json" +WEATHER_SUBSYSTEM_CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianWeatherProviderSubsystem.cpp" +WEATHER_SUBSYSTEM_H = ROOT / "Source" / "AgrarianGame" / "AgrarianWeatherProviderSubsystem.h" +ROADMAP = ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md" +TDD = ROOT / "Docs" / "TechnicalDesignDocument.md" + + +EXPECTED_SNIPPETS = { + WEATHER_SUBSYSTEM_H: [ + "OpenMeteoForecastEndpoint", + "https://api.open-meteo.com/v1/forecast", + "RequestWeatherForTile", + ], + WEATHER_SUBSYSTEM_CPP: [ + "current=temperature_2m,relative_humidity_2m,precipitation", + "daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum,wind_speed_10m_max", + "OutSnapshot.Provider = TEXT(\"open-meteo\");", + ], + ROADMAP: [ + "[x] Use Open-Meteo as the first global MVP weather source.", + ], + TDD: [ + "Open-Meteo is the first global MVP weather source", + "Data/Weather/open_meteo_mvp_source.json", + ], +} + + +def assert_static_contract() -> tuple[dict, dict]: + missing = [] + for path, snippets in EXPECTED_SNIPPETS.items(): + text = path.read_text(encoding="utf-8") + for snippet in snippets: + if snippet not in text: + missing.append(f"{path.relative_to(ROOT)}: {snippet}") + if missing: + raise RuntimeError("Open-Meteo MVP source verification failed: " + "; ".join(missing)) + + provider = json.loads(PROVIDER_CONTRACT.read_text(encoding="utf-8")) + manifest = json.loads(TILE_MANIFEST.read_text(encoding="utf-8")) + if provider.get("provider_id") != "open-meteo": + raise RuntimeError("Provider contract does not identify Open-Meteo") + if provider.get("api_key_required") is not False: + raise RuntimeError("Open-Meteo MVP source should not require an API key") + if not manifest.get("tiles"): + raise RuntimeError("Tile weather manifest has no source-backed weather tiles") + for tile in manifest["tiles"]: + if tile.get("provider") != "open-meteo": + raise RuntimeError(f"Unexpected tile weather provider for {tile.get('tile_id')}: {tile.get('provider')}") + return provider, manifest + + +def build_open_meteo_url(provider: dict, tile: dict) -> str: + params = provider["request_parameters"] + query = { + "latitude": f"{float(tile['center_latitude']):.6f}", + "longitude": f"{float(tile['center_longitude']):.6f}", + "current": ",".join(params["current"]), + "daily": ",".join(params["daily"]), + "forecast_days": str(params["forecast_days"]), + "timezone": params["timezone"], + } + return provider["forecast_endpoint"] + "?" + urllib.parse.urlencode(query, safe=",") + + +def assert_live_open_meteo_response(provider: dict, manifest: dict) -> None: + if os.environ.get("AGRARIAN_SKIP_LIVE_WEATHER_CHECK") == "1": + print("Skipping live Open-Meteo check because AGRARIAN_SKIP_LIVE_WEATHER_CHECK=1") + return + + for tile in manifest["tiles"]: + url = build_open_meteo_url(provider, tile) + with urllib.request.urlopen(url, timeout=20) as response: + payload = json.loads(response.read().decode("utf-8")) + + current = payload.get("current", {}) + daily = payload.get("daily", {}) + required_current = {"temperature_2m", "relative_humidity_2m", "precipitation", "weather_code", "wind_speed_10m"} + required_daily = {"temperature_2m_max", "temperature_2m_min", "weather_code", "precipitation_sum"} + missing_current = sorted(required_current - set(current)) + missing_daily = sorted(required_daily - set(daily)) + if missing_current or missing_daily: + raise RuntimeError( + f"Open-Meteo response for {tile['tile_id']} missing current={missing_current} daily={missing_daily}" + ) + if not isinstance(daily["temperature_2m_max"], list) or not daily["temperature_2m_max"]: + raise RuntimeError(f"Open-Meteo daily max temperature is empty for {tile['tile_id']}") + + +def main() -> None: + provider, manifest = assert_static_contract() + assert_live_open_meteo_response(provider, manifest) + print("Open-Meteo MVP weather source verification complete.") + + +if __name__ == "__main__": + main()