Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions fixtures/providers/met_locationforecast_compact.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
]
}
}
76 changes: 76 additions & 0 deletions fixtures/providers/met_normalized_expected.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
1 change: 1 addition & 0 deletions src/app/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Providers package
105 changes: 105 additions & 0 deletions src/app/providers/met.py
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading