wttr/src/weather/MetNo.zig

478 lines
22 KiB
Zig

const std = @import("std");
const WeatherProvider = @import("Provider.zig");
const Coordinates = @import("../Coordinates.zig");
const types = @import("types.zig");
const Cache = @import("../cache/Cache.zig");
const MetNo = @This();
const MetNoOpenWeatherEntry = struct { []const u8, types.WeatherCode };
// symbol codes: https://github.com/metno/weathericons/tree/main/weather
// they also have _day, _night and _polartwilight variants
//
// Openweathermap weather condition codes:
// https://openweathermap.org/weather-conditions
const weather_code_entries = [_]MetNoOpenWeatherEntry{
// zig fmt: off
.{ "clearsky", .clear },
.{ "cloudy", .clouds_overcast },
.{ "fair", .clouds_few },
.{ "fog", .fog },
.{ "heavyrain", .rain_heavy },
.{ "heavyrainandthunder", .thunderstorm_heavy_rain },
.{ "heavyrainshowers", .thunderstorm_drizzle },
.{ "heavyrainshowersandthunder", .thunderstorm_heavy_drizzle },
.{ "heavysleet", .sleet },
.{ "heavysleetandthunder", .thunderstorm_heavy_rain },
.{ "heavysleetshowers", .sleet_shower },
.{ "heavysleetshowersandthunder", .thunderstorm_heavy_drizzle },
.{ "heavysnow", .snow_heavy },
.{ "heavysnowandthunder", .thunderstorm_heavy },
.{ "heavysnowshowers", .snow_shower_heavy },
.{ "heavysnowshowersandthunder", .thunderstorm_heavy },
.{ "lightrain", .rain_light },
.{ "lightrainandthunder", .thunderstorm_light_rain },
.{ "lightrainshowers", .rain_shower_light },
.{ "lightrainshowersandthunder", .thunderstorm_light_rain },
.{ "lightsleet", .sleet_shower_light },
.{ "lightsleetandthunder", .thunderstorm_light },
.{ "lightsleetshowers", .sleet_shower_light },
.{ "lightsnow", .snow_light },
.{ "lightsnowandthunder", .thunderstorm_light },
.{ "lightsnowshowers", .snow_shower_light },
.{ "lightssleetshowersandthunder", .thunderstorm_light },
.{ "lightssnowshowersandthunder", .thunderstorm_light },
.{ "partlycloudy", .clouds_few },
.{ "rain", .rain_moderate },
.{ "rainandthunder", .thunderstorm_rain },
.{ "rainshowers", .rain_shower },
.{ "rainshowersandthunder", .thunderstorm_drizzle },
.{ "sleet", .sleet },
.{ "sleetandthunder", .thunderstorm },
.{ "sleetshowers", .sleet_shower },
.{ "sleetshowersandthunder", .thunderstorm_drizzle },
.{ "snow", .snow },
.{ "snowandthunder", .thunderstorm },
.{ "snowshowers", .snow_shower },
.{ "snowshowersandthunder", .thunderstorm },
// zig fmt: on
};
const WeatherCodeMap = std.StaticStringMap(types.WeatherCode);
const weather_code_map = WeatherCodeMap.initComptime(weather_code_entries);
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) !MetNo {
return MetNo{
.allocator = allocator,
};
}
pub fn provider(self: *MetNo, cache: *Cache) WeatherProvider {
return .{
.ptr = self,
.cache = cache,
.vtable = &.{
.fetchRaw = fetchRaw,
.parse = parse,
.deinit = deinitProvider,
},
};
}
fn fetchRaw(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) ![]const u8 {
const self: *MetNo = @ptrCast(@alignCast(ptr));
const url = try std.fmt.allocPrint(
self.allocator,
"https://api.met.no/weatherapi/locationforecast/2.0/compact?lat={d:.4}&lon={d:.4}",
.{ coords.latitude, coords.longitude },
);
defer self.allocator.free(url);
// Fetch weather data from met.no API
var client = std.http.Client{ .allocator = self.allocator };
defer client.deinit();
const uri = try std.Uri.parse(url);
var response_buf: [1024 * 1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&response_buf);
const result = try client.fetch(.{
.location = .{ .uri = uri },
.method = .GET,
.response_writer = &writer,
.extra_headers = &.{
.{ .name = "User-Agent", .value = "wttr.in-zig/1.0 github.com/chubin/wttr.in" },
},
});
if (result.status != .ok) {
return error.WeatherApiFailed;
}
const response_body = response_buf[0..writer.end];
return try allocator.dupe(u8, response_body);
}
fn parse(ptr: *anyopaque, allocator: std.mem.Allocator, raw: []const u8) !types.WeatherData {
_ = ptr;
// Parse JSON response
const parsed = try std.json.parseFromSlice(
std.json.Value,
allocator,
raw,
.{},
);
defer parsed.deinit();
// Extract coordinates from the response (we don't have them passed in)
const geometry = parsed.value.object.get("geometry") orelse return error.InvalidResponse;
const coordinates = geometry.object.get("coordinates") orelse return error.InvalidResponse;
const lon: f64 = coordinates.array.items[0].float;
const lat: f64 = coordinates.array.items[1].float;
const coords = Coordinates{ .latitude = lat, .longitude = lon };
return try parseMetNoResponse(allocator, coords, parsed.value);
}
fn deinitProvider(ptr: *anyopaque) void {
const self: *MetNo = @ptrCast(@alignCast(ptr));
self.deinit();
}
pub fn deinit(self: *MetNo) void {
_ = self;
}
fn parseMetNoResponse(allocator: std.mem.Allocator, coords: Coordinates, json: std.json.Value) !types.WeatherData {
const properties = json.object.get("properties") orelse return error.InvalidResponse;
const timeseries = properties.object.get("timeseries") orelse return error.InvalidResponse;
if (timeseries.array.items.len == 0) return error.InvalidResponse;
const current = timeseries.array.items[0];
const data = current.object.get("data") orelse return error.InvalidResponse;
const instant = data.object.get("instant") orelse return error.InvalidResponse;
const details = instant.object.get("details") orelse return error.InvalidResponse;
const temp_c: f32 = @floatCast(details.object.get("air_temperature").?.float);
const humidity: u8 = @intFromFloat(details.object.get("relative_humidity").?.float);
const wind_speed_ms = details.object.get("wind_speed").?.float;
const wind_kph: f32 = @floatCast(wind_speed_ms * 3.6);
const pressure_mb: f32 = @floatCast(details.object.get("air_pressure_at_sea_level").?.float);
// Get weather symbol
const next_1h = data.object.get("next_1_hours");
const symbol_code = if (next_1h) |n1h|
n1h.object.get("summary").?.object.get("symbol_code").?.string
else
"clearsky_day";
// Get wind direction
const wind_from_deg = details.object.get("wind_from_direction");
const wind_dir = if (wind_from_deg) |deg|
degreeToDirection(@as(f32, @floatCast(deg.float)))
else
"N";
// Parse forecast days from timeseries
const forecast = try parseForecastDays(allocator, timeseries.array.items);
const feels_like_c = temp_c; // TODO: Calculate wind chill
return types.WeatherData{
.location = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude }),
.current = .{
.temp_c = temp_c,
.temp_f = temp_c * 9.0 / 5.0 + 32.0,
.feels_like_c = feels_like_c,
.feels_like_f = feels_like_c * 9.0 / 5.0 + 32.0,
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)),
.weather_code = symbolCodeToWeatherCode(symbol_code),
.humidity = humidity,
.wind_kph = wind_kph,
.wind_dir = try allocator.dupe(u8, wind_dir),
.pressure_mb = pressure_mb,
.precip_mm = 0.0,
},
.forecast = forecast,
.allocator = allocator,
};
}
fn clearHourlyForecast(allocator: std.mem.Allocator, forecast: *std.ArrayList(types.HourlyForecast)) void {
for (forecast.items) |h| {
allocator.free(h.time);
allocator.free(h.condition);
}
forecast.clearRetainingCapacity();
}
fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value) ![]types.ForecastDay {
var days: std.ArrayList(types.ForecastDay) = .empty;
errdefer days.deinit(allocator);
var current_date: ?[]const u8 = null;
var day_temps: std.ArrayList(f32) = .empty;
defer day_temps.deinit(allocator);
var day_hourly: std.ArrayList(types.HourlyForecast) = .empty;
defer {
for (day_hourly.items) |h| {
allocator.free(h.time);
allocator.free(h.condition);
}
day_hourly.deinit(allocator);
}
var day_all_hours: std.ArrayList(types.HourlyForecast) = .empty;
defer {
for (day_all_hours.items) |h| {
allocator.free(h.time);
allocator.free(h.condition);
}
day_all_hours.deinit(allocator);
}
var day_symbol: ?[]const u8 = null;
for (timeseries) |entry| {
const time_str = entry.object.get("time").?.string;
const date = time_str[0..10];
const hour = time_str[11..13];
const data = entry.object.get("data") orelse continue;
const instant = data.object.get("instant") orelse continue;
const details_obj = instant.object.get("details") orelse continue;
const temp: f32 = @floatCast(details_obj.object.get("air_temperature").?.float);
const wind_ms = details_obj.object.get("wind_speed") orelse continue;
const wind_kph: f32 = @floatCast(wind_ms.float * 3.6);
if (current_date == null or !std.mem.eql(u8, current_date.?, date)) {
// Save previous day if exists
if (current_date != null and day_temps.items.len > 0) {
var max_temp: f32 = day_temps.items[0];
var min_temp: f32 = day_temps.items[0];
for (day_temps.items) |t| {
if (t > max_temp) max_temp = t;
if (t < min_temp) min_temp = t;
}
const symbol = day_symbol orelse "clearsky_day";
// Use preferred times if we have 4, otherwise pick 4 evenly spaced from all hours
const hourly_slice: []types.HourlyForecast = blk: {
if (day_hourly.items.len >= 4) {
const hrs = try day_hourly.toOwnedSlice(allocator);
clearHourlyForecast(allocator, &day_all_hours);
break :blk hrs;
}
clearHourlyForecast(allocator, &day_all_hours);
if (day_all_hours.items.len < 4)
break :blk try day_all_hours.toOwnedSlice(allocator);
// Pick 4 evenly spaced entries from day_all_hours
if (day_all_hours.items.len >= 4) {
const step = day_all_hours.items.len / 4;
var selected: std.ArrayList(types.HourlyForecast) = .empty;
try selected.append(allocator, day_all_hours.items[0]);
try selected.append(allocator, day_all_hours.items[step]);
try selected.append(allocator, day_all_hours.items[step * 2]);
try selected.append(allocator, day_all_hours.items[step * 3]);
const hrs = try selected.toOwnedSlice(allocator);
// Free the rest
for (day_all_hours.items, 0..) |h, i| {
if (i != 0 and i != step and i != step * 2 and i != step * 3) {
allocator.free(h.time);
allocator.free(h.condition);
}
}
day_all_hours.clearRetainingCapacity();
break :blk hrs;
}
break :blk try day_all_hours.toOwnedSlice(allocator);
};
try days.append(allocator, .{
.date = try allocator.dupe(u8, current_date.?),
.max_temp_c = max_temp,
.min_temp_c = min_temp,
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol)),
.weather_code = symbolCodeToWeatherCode(symbol),
.hourly = hourly_slice,
});
if (days.items.len >= 3) break;
}
// Start new day
current_date = date;
day_temps.clearRetainingCapacity();
day_all_hours.clearRetainingCapacity();
day_symbol = null;
}
try day_temps.append(allocator, temp);
// Collect ALL hourly forecasts
const next_1h = data.object.get("next_1_hours");
if (next_1h) |n1h| {
const symbol_code = n1h.object.get("summary").?.object.get("symbol_code").?.string;
const precip = if (n1h.object.get("details")) |det| blk: {
if (det.object.get("precipitation_amount")) |p| {
break :blk @as(f32, @floatCast(p.float));
}
break :blk @as(f32, 0.0);
} else 0.0;
try day_all_hours.append(allocator, .{
.time = try allocator.dupe(u8, time_str[11..16]),
.temp_c = temp,
.feels_like_c = temp,
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)),
.weather_code = symbolCodeToWeatherCode(symbol_code),
.wind_kph = wind_kph,
.precip_mm = precip,
});
}
// Collect preferred hourly forecasts for 06:00, 12:00, 18:00, 00:00
if (std.mem.eql(u8, hour, "06") or std.mem.eql(u8, hour, "12") or
std.mem.eql(u8, hour, "18") or std.mem.eql(u8, hour, "00")) {
if (next_1h) |n1h| {
const symbol_code = n1h.object.get("summary").?.object.get("symbol_code").?.string;
const precip = if (n1h.object.get("details")) |det| blk: {
if (det.object.get("precipitation_amount")) |p| {
break :blk @as(f32, @floatCast(p.float));
}
break :blk @as(f32, 0.0);
} else 0.0;
try day_hourly.append(allocator, .{
.time = try allocator.dupe(u8, time_str[11..16]),
.temp_c = temp,
.feels_like_c = temp,
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)),
.weather_code = symbolCodeToWeatherCode(symbol_code),
.wind_kph = wind_kph,
.precip_mm = precip,
});
}
}
// Get symbol for the day
if (day_symbol == null) {
const next_6h = data.object.get("next_6_hours");
if (next_6h) |n6h| {
if (n6h.object.get("summary")) |summary| {
day_symbol = summary.object.get("symbol_code").?.string;
}
}
}
}
return days.toOwnedSlice(allocator);
}
fn symbolCodeToWeatherCode(symbol: []const u8) types.WeatherCode {
var it = std.mem.splitScalar(u8, symbol, '_');
const metno_weather_code = it.next().?;
const variant = it.next();
_ = variant; // discard day/night/polar twilight for now
return weather_code_map.get(metno_weather_code) orelse .unknown;
}
fn symbolCodeToCondition(symbol: []const u8) []const u8 {
var it = std.mem.splitScalar(u8, symbol, '_');
const metno_weather_code = it.next().?;
if (std.mem.eql(u8, metno_weather_code, "clearsky")) return "Clear";
if (std.mem.eql(u8, metno_weather_code, "partlycloudy")) return "Partly cloudy";
if (std.mem.eql(u8, metno_weather_code, "fair")) return "Fair";
if (std.mem.eql(u8, metno_weather_code, "cloudy")) return "Cloudy";
if (std.mem.eql(u8, metno_weather_code, "fog")) return "Fog";
if (std.mem.eql(u8, metno_weather_code, "thunder")) return "Thunderstorm";
if (std.mem.eql(u8, metno_weather_code, "rain")) return "Rain";
if (std.mem.eql(u8, metno_weather_code, "sleet")) return "Sleet";
if (std.mem.eql(u8, metno_weather_code, "snow")) return "Snow";
return "Unknown";
}
fn degreeToDirection(deg: f32) []const u8 {
const normalized = @mod(deg + 22.5, 360.0);
const idx: usize = @intFromFloat(normalized / 45.0);
const directions = [_][]const u8{ "N", "NE", "E", "SE", "S", "SW", "W", "NW" };
return directions[@min(idx, 7)];
}
test "degreeToDirection" {
try std.testing.expectEqualStrings("N", degreeToDirection(0));
try std.testing.expectEqualStrings("NE", degreeToDirection(45));
try std.testing.expectEqualStrings("E", degreeToDirection(90));
try std.testing.expectEqualStrings("SE", degreeToDirection(135));
try std.testing.expectEqualStrings("S", degreeToDirection(180));
try std.testing.expectEqualStrings("SW", degreeToDirection(225));
try std.testing.expectEqualStrings("W", degreeToDirection(270));
try std.testing.expectEqualStrings("NW", degreeToDirection(315));
}
test "symbolCodeToWeatherCode" {
try std.testing.expectEqual(types.WeatherCode.clear, symbolCodeToWeatherCode("clearsky_day"));
try std.testing.expectEqual(types.WeatherCode.clouds_few, symbolCodeToWeatherCode("fair_night"));
try std.testing.expectEqual(types.WeatherCode.clouds_overcast, symbolCodeToWeatherCode("cloudy"));
try std.testing.expectEqual(types.WeatherCode.rain_light, symbolCodeToWeatherCode("lightrain"));
try std.testing.expectEqual(types.WeatherCode.snow, symbolCodeToWeatherCode("snow"));
}
test "parseForecastDays extracts 3 days" {
const allocator = std.testing.allocator;
const json_str =
\\{"properties":{"timeseries":[
\\ {"time":"2025-12-20T00:00:00Z","data":{"instant":{"details":{"air_temperature":10.0,"wind_speed":5.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_day"}}}},
\\ {"time":"2025-12-20T06:00:00Z","data":{"instant":{"details":{"air_temperature":15.0,"wind_speed":6.0}},"next_1_hours":{"summary":{"symbol_code":"clearsky_day"}}}},
\\ {"time":"2025-12-20T12:00:00Z","data":{"instant":{"details":{"air_temperature":20.0,"wind_speed":7.0}},"next_1_hours":{"summary":{"symbol_code":"clearsky_day"}}}},
\\ {"time":"2025-12-20T18:00:00Z","data":{"instant":{"details":{"air_temperature":12.0,"wind_speed":5.0}},"next_1_hours":{"summary":{"symbol_code":"clearsky_day"}}}},
\\ {"time":"2025-12-21T00:00:00Z","data":{"instant":{"details":{"air_temperature":8.0,"wind_speed":8.0}},"next_6_hours":{"summary":{"symbol_code":"rain"}},"next_1_hours":{"summary":{"symbol_code":"rain"}}}},
\\ {"time":"2025-12-21T12:00:00Z","data":{"instant":{"details":{"air_temperature":14.0,"wind_speed":10.0}},"next_1_hours":{"summary":{"symbol_code":"rain"}}}},
\\ {"time":"2025-12-22T00:00:00Z","data":{"instant":{"details":{"air_temperature":5.0,"wind_speed":4.0}},"next_6_hours":{"summary":{"symbol_code":"snow"}},"next_1_hours":{"summary":{"symbol_code":"snow"}}}},
\\ {"time":"2025-12-22T12:00:00Z","data":{"instant":{"details":{"air_temperature":7.0,"wind_speed":3.0}},"next_1_hours":{"summary":{"symbol_code":"snow"}}}},
\\ {"time":"2025-12-23T00:00:00Z","data":{"instant":{"details":{"air_temperature":6.0,"wind_speed":2.0}}}}
\\]}}
;
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_str, .{});
defer parsed.deinit();
const properties = parsed.value.object.get("properties").?;
const timeseries = properties.object.get("timeseries").?;
const forecast = try parseForecastDays(allocator, timeseries.array.items);
defer {
for (forecast) |day| {
allocator.free(day.date);
allocator.free(day.condition);
for (day.hourly) |hour| {
allocator.free(hour.time);
allocator.free(hour.condition);
}
allocator.free(day.hourly);
}
allocator.free(forecast);
}
try std.testing.expectEqual(@as(usize, 3), forecast.len);
try std.testing.expectEqualStrings("2025-12-20", forecast[0].date);
try std.testing.expectEqual(@as(f32, 20.0), forecast[0].max_temp_c);
try std.testing.expectEqual(@as(f32, 10.0), forecast[0].min_temp_c);
try std.testing.expectEqual(types.WeatherCode.clear, forecast[0].weather_code);
}
test "parseForecastDays handles empty timeseries" {
const allocator = std.testing.allocator;
const empty: []std.json.Value = &.{};
const forecast = try parseForecastDays(allocator, empty);
defer allocator.free(forecast);
try std.testing.expectEqual(@as(usize, 0), forecast.len);
}