diff --git a/fixtures/providers/met_locationforecast_compact.json b/fixtures/providers/met_locationforecast_compact.json new file mode 100644 index 00000000..50174477 --- /dev/null +++ b/fixtures/providers/met_locationforecast_compact.json @@ -0,0 +1,136 @@ +{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [9.0, 42.4, 0] + }, + "properties": { + "meta": { + "updated_at": "2025-08-29T06:15:00Z", + "units": { + "air_pressure_at_sea_level": "hPa", + "air_temperature": "celsius", + "cloud_area_fraction": "%", + "precipitation_amount": "mm", + "relative_humidity": "%", + "wind_from_direction": "degrees", + "wind_speed": "m/s", + "wind_speed_of_gust": "m/s" + } + }, + "timeseries": [ + { + "time": "2025-08-29T12:00:00Z", + "data": { + "instant": { + "details": { + "air_pressure_at_sea_level": 1013.2, + "air_temperature": 18.5, + "cloud_area_fraction": 85.0, + "relative_humidity": 78.0, + "wind_from_direction": 220.5, + "wind_speed": 6.1, + "wind_speed_of_gust": 10.6 + } + }, + "next_1_hours": { + "summary": { + "symbol_code": "lightrain" + }, + "details": { + "precipitation_amount": 0.4 + } + }, + "next_6_hours": { + "summary": { + "symbol_code": "rain" + }, + "details": { + "precipitation_amount": 3.2 + } + } + } + }, + { + "time": "2025-08-29T13:00:00Z", + "data": { + "instant": { + "details": { + "air_pressure_at_sea_level": 1012.8, + "air_temperature": 19.2, + "cloud_area_fraction": 90.0, + "relative_humidity": 82.0, + "wind_from_direction": 225.0, + "wind_speed": 7.5, + "wind_speed_of_gust": 12.3 + } + }, + "next_1_hours": { + "summary": { + "symbol_code": "lightrainandthunder" + }, + "details": { + "precipitation_amount": 1.2 + } + }, + "next_6_hours": { + "summary": { + "symbol_code": "heavyrainandthunder" + }, + "details": { + "precipitation_amount": 8.5 + } + } + } + }, + { + "time": "2025-08-29T14:00:00Z", + "data": { + "instant": { + "details": { + "air_pressure_at_sea_level": 1011.5, + "air_temperature": 17.8, + "cloud_area_fraction": 95.0, + "relative_humidity": 88.0, + "wind_from_direction": 230.0, + "wind_speed": 8.9, + "wind_speed_of_gust": 15.2 + } + }, + "next_1_hours": { + "summary": { + "symbol_code": "heavyrain" + }, + "details": { + "precipitation_amount": 3.5 + } + } + } + }, + { + "time": "2025-08-29T15:00:00Z", + "data": { + "instant": { + "details": { + "air_pressure_at_sea_level": 1011.0, + "air_temperature": 16.5, + "cloud_area_fraction": 75.0, + "relative_humidity": 80.0, + "wind_from_direction": 235.0, + "wind_speed": 5.5, + "wind_speed_of_gust": 9.2 + } + }, + "next_1_hours": { + "summary": { + "symbol_code": "partlycloudy_day" + }, + "details": { + "precipitation_amount": 0.0 + } + } + } + } + ] + } +} diff --git a/fixtures/providers/met_normalized_expected.json b/fixtures/providers/met_normalized_expected.json new file mode 100644 index 00000000..ce87b5f1 --- /dev/null +++ b/fixtures/providers/met_normalized_expected.json @@ -0,0 +1,76 @@ +{ + "meta": { + "provider": "MET", + "model": "ECMWF", + "run": "2025-08-29T06:15:00Z", + "grid_res_km": 9, + "interp": "point_grid", + "stations_used": [] + }, + "data": [ + { + "ts": "2025-08-29T12:00:00Z", + "t2m_c": 18.5, + "wind10m_kmh": 22.0, + "gust_kmh": 38.2, + "precip_rate_mmph": 0.4, + "precip_1h_mm": 0.4, + "cloud_total_pct": 85, + "symbol": "lightrain", + "thunder_level": "NONE", + "cape_jkg": null, + "pop_pct": null, + "pressure_msl_hpa": 1013.2, + "humidity_pct": 78, + "dewpoint_c": null + }, + { + "ts": "2025-08-29T13:00:00Z", + "t2m_c": 19.2, + "wind10m_kmh": 27.0, + "gust_kmh": 44.3, + "precip_rate_mmph": 1.2, + "precip_1h_mm": 1.2, + "cloud_total_pct": 90, + "symbol": "lightrainandthunder", + "thunder_level": "HIGH", + "cape_jkg": null, + "pop_pct": null, + "pressure_msl_hpa": 1012.8, + "humidity_pct": 82, + "dewpoint_c": null + }, + { + "ts": "2025-08-29T14:00:00Z", + "t2m_c": 17.8, + "wind10m_kmh": 32.0, + "gust_kmh": 54.7, + "precip_rate_mmph": 3.5, + "precip_1h_mm": 3.5, + "cloud_total_pct": 95, + "symbol": "heavyrain", + "thunder_level": "NONE", + "cape_jkg": null, + "pop_pct": null, + "pressure_msl_hpa": 1011.5, + "humidity_pct": 88, + "dewpoint_c": null + }, + { + "ts": "2025-08-29T15:00:00Z", + "t2m_c": 16.5, + "wind10m_kmh": 19.8, + "gust_kmh": 33.1, + "precip_rate_mmph": 0.0, + "precip_1h_mm": 0.0, + "cloud_total_pct": 75, + "symbol": "partlycloudy", + "thunder_level": "NONE", + "cape_jkg": null, + "pop_pct": null, + "pressure_msl_hpa": 1011.0, + "humidity_pct": 80, + "dewpoint_c": null + } + ] +} diff --git a/src/app/providers/__init__.py b/src/app/providers/__init__.py new file mode 100644 index 00000000..329fed95 --- /dev/null +++ b/src/app/providers/__init__.py @@ -0,0 +1 @@ +# Providers package diff --git a/src/app/providers/met.py b/src/app/providers/met.py new file mode 100644 index 00000000..133c2eb5 --- /dev/null +++ b/src/app/providers/met.py @@ -0,0 +1,105 @@ +"""MET Norway LocationForecast provider adapter.""" + +from typing import Any + + +def ms_to_kmh(value: float) -> float: + """Convert m/s to km/h.""" + return round(value * 3.6, 1) + + +def map_symbol(symbol_code: str) -> str: + """ + Map MET symbol_code to normalized symbol. + + MET symbols may have suffixes like _day/_night/_polartwilight. + We strip these and normalize to the canonical symbol set. + """ + # Strip time-of-day suffixes + base_symbol = symbol_code.replace("_day", "").replace("_night", "").replace("_polartwilight", "") + + # Direct mapping for known symbols + # MET symbols align well with our canonical set + return base_symbol + + +def get_thunder_level(symbol_code: str) -> str: + """ + Determine thunder_level from symbol_code. + + According to api_contract.md: + - MET: symbol_code contains "thunder" => HIGH, else NONE + """ + if "thunder" in symbol_code.lower(): + return "HIGH" + return "NONE" + + +def normalize(api_response: dict[str, Any]) -> dict[str, Any]: + """ + Normalize MET Norway LocationForecast API response to schema format. + + Args: + api_response: Raw MET API response (GeoJSON format) + + Returns: + Normalized forecast timeseries conforming to normalized_timeseries.schema.json + """ + properties = api_response["properties"] + meta_data = properties["meta"] + timeseries = properties["timeseries"] + + # Build meta + meta = { + "provider": "MET", + "model": "ECMWF", # MET uses ECMWF for most forecasts + "run": meta_data["updated_at"], + "grid_res_km": 9, # MET Norway uses ~9km grid resolution + "interp": "point_grid", # Point forecast, no station interpolation + "stations_used": [] + } + + # Build data array + data = [] + for item in timeseries: + instant = item["data"]["instant"]["details"] + next_1h = item["data"].get("next_1_hours", {}) + next_1h_details = next_1h.get("details", {}) + next_1h_summary = next_1h.get("summary", {}) + + # Get precipitation (from next_1_hours) + precip_1h = next_1h_details.get("precipitation_amount", 0.0) + + # Get symbol (from next_1_hours summary) + symbol_code = next_1h_summary.get("symbol_code", "clearsky") + + # Required fields + data_point = { + "ts": item["time"], + "t2m_c": instant["air_temperature"], + "wind10m_kmh": ms_to_kmh(instant["wind_speed"]), + "precip_rate_mmph": precip_1h, # 1h amount = rate in mm/h + "cloud_total_pct": int(instant["cloud_area_fraction"]), + "symbol": map_symbol(symbol_code), + "thunder_level": get_thunder_level(symbol_code), + } + + # Optional fields (only add if not None) + gust = instant.get("wind_speed_of_gust") + if gust is not None: + data_point["gust_kmh"] = ms_to_kmh(gust) + + if precip_1h is not None: + data_point["precip_1h_mm"] = precip_1h + + data_point["pressure_msl_hpa"] = instant["air_pressure_at_sea_level"] + data_point["humidity_pct"] = int(instant["relative_humidity"]) + + # Fields MET does not provide: cape_jkg, pop_pct, dewpoint_c + # We omit them entirely rather than setting to None + data.append(data_point) + + return { + "meta": meta, + "data": data + } diff --git a/tests/test_provider_met.py b/tests/test_provider_met.py new file mode 100644 index 00000000..e4dd0842 --- /dev/null +++ b/tests/test_provider_met.py @@ -0,0 +1,105 @@ +import json +import pytest +from pathlib import Path +from jsonschema import validate + + +@pytest.fixture +def met_api_response(): + """Load MET API fixture.""" + fixture_path = Path(__file__).parent.parent / "fixtures/providers/met_locationforecast_compact.json" + with open(fixture_path, "r", encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture +def expected_normalized(): + """Load expected normalized output fixture.""" + fixture_path = Path(__file__).parent.parent / "fixtures/providers/met_normalized_expected.json" + with open(fixture_path, "r", encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture +def schema(): + """Load JSON schema.""" + schema_path = Path(__file__).parent.parent / "schemas/normalized_timeseries.schema.json" + with open(schema_path, "r", encoding="utf-8") as f: + return json.load(f) + + +def test_normalize_met_response(met_api_response, expected_normalized, schema): + """Test normalization of MET API response to schema-compliant format.""" + from app.providers.met import normalize + + result = normalize(met_api_response) + + # Validate against schema + validate(instance=result, schema=schema) + + # Check meta fields + assert result["meta"]["provider"] == "MET" + assert result["meta"]["model"] == "ECMWF" + assert result["meta"]["run"] == "2025-08-29T06:15:00Z" + assert result["meta"]["grid_res_km"] == 9 + assert result["meta"]["interp"] == "point_grid" + + # Check data length + assert len(result["data"]) == 4 + + # Check first data point in detail + first = result["data"][0] + expected_first = expected_normalized["data"][0] + + assert first["ts"] == expected_first["ts"] + assert first["t2m_c"] == expected_first["t2m_c"] + assert first["wind10m_kmh"] == pytest.approx(expected_first["wind10m_kmh"], abs=0.1) + assert first["gust_kmh"] == pytest.approx(expected_first["gust_kmh"], abs=0.1) + assert first["precip_rate_mmph"] == expected_first["precip_rate_mmph"] + assert first["precip_1h_mm"] == expected_first["precip_1h_mm"] + assert first["cloud_total_pct"] == expected_first["cloud_total_pct"] + assert first["symbol"] == expected_first["symbol"] + assert first["thunder_level"] == expected_first["thunder_level"] + assert first["pressure_msl_hpa"] == expected_first["pressure_msl_hpa"] + assert first["humidity_pct"] == expected_first["humidity_pct"] + + +def test_symbol_mapping(): + """Test MET symbol_code to normalized symbol mapping.""" + from app.providers.met import map_symbol + + # Direct mapping (no suffix) + assert map_symbol("lightrain") == "lightrain" + assert map_symbol("heavyrain") == "heavyrain" + assert map_symbol("clearsky_day") == "clearsky" + assert map_symbol("clearsky_night") == "clearsky" + assert map_symbol("partlycloudy_day") == "partlycloudy" + assert map_symbol("partlycloudy_night") == "partlycloudy" + + # Thunder variants + assert map_symbol("lightrainandthunder") == "lightrainandthunder" + assert map_symbol("heavyrainandthunder") == "heavyrainandthunder" + + +def test_thunder_level_detection(): + """Test thunder_level detection from symbol_code.""" + from app.providers.met import get_thunder_level + + # HIGH when symbol contains "thunder" + assert get_thunder_level("lightrainandthunder") == "HIGH" + assert get_thunder_level("heavyrainandthunder") == "HIGH" + assert get_thunder_level("thunderstorm") == "HIGH" + + # NONE otherwise + assert get_thunder_level("lightrain") == "NONE" + assert get_thunder_level("heavyrain") == "NONE" + assert get_thunder_level("clearsky_day") == "NONE" + + +def test_unit_conversions(): + """Test m/s to km/h conversions.""" + from app.providers.met import ms_to_kmh + + assert ms_to_kmh(6.1) == pytest.approx(22.0, abs=0.1) + assert ms_to_kmh(10.6) == pytest.approx(38.2, abs=0.1) + assert ms_to_kmh(0.0) == 0.0