wttr/src/weather/MetNo.zig
Emil Lerch 9bdaaa64d7
Some checks failed
Generic zig build / build (push) Failing after 12s
Generic zig build / deploy (push) Has been skipped
clean up error message
2026-01-05 13:44:49 -08:00

497 lines
22 KiB
Zig

const std = @import("std");
const build_options = @import("build_options");
const WeatherProvider = @import("Provider.zig");
const Coordinates = @import("../Coordinates.zig");
const types = @import("types.zig");
const Cache = @import("../cache/Cache.zig");
const zeit = @import("zeit");
const MetNo = @This();
pub const MissingIdentificationError = error.MetNoIdentificationRequired;
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,
identifying_email: []const u8,
pub fn init(allocator: std.mem.Allocator, identifying_email: ?[]const u8) !MetNo {
const email = identifying_email orelse blk: {
const env_email = std.process.getEnvVarOwned(allocator, "METNO_TOS_IDENTIFYING_EMAIL") catch |err| {
if (err == error.EnvironmentVariableNotFound) {
std.log.err("Met.no Terms of Service require identification. Set METNO_TOS_IDENTIFYING_EMAIL environment variable", .{});
std.log.err("See \x1b]8;;https://api.met.no/doc/TermsOfService\x1b\\https://api.met.no/doc/TermsOfService\x1b]8;;\x1b\\ for more information", .{});
return MissingIdentificationError;
}
return err;
};
break :blk env_email;
};
return MetNo{
.allocator = allocator,
.identifying_email = email,
};
}
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 user_agent = try std.fmt.allocPrint(
self.allocator,
"wttr/{s} git.lerch.org/lobo/wttr {s}",
.{ build_options.version, self.identifying_email },
);
defer self.allocator.free(user_agent);
const result = try client.fetch(.{
.location = .{ .uri = uri },
.method = .GET,
.response_writer = &writer,
.extra_headers = &.{
.{ .name = "User-Agent", .value = user_agent },
},
});
if (result.status != .ok) {
return error.WeatherApiFailed;
}
const response_body = response_buf[0..writer.end];
return try allocator.dupe(u8, response_body);
}
pub 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.allocator.free(self.identifying_email);
}
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_deg = if (details.object.get("wind_from_direction")) |deg|
@as(f32, @floatCast(deg.float))
else
0.0;
// Parse forecast days from timeseries
const forecast = try parseForecastDays(allocator, timeseries.array.items, coords);
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 }),
.coords = coords,
.current = .{
.temp_c = temp_c,
.feels_like_c = feels_like_c,
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)),
.weather_code = symbolCodeToWeatherCode(symbol_code),
.humidity = humidity,
.wind_kph = wind_kph,
.wind_deg = wind_deg,
.pressure_mb = pressure_mb,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = forecast,
.allocator = allocator,
};
}
fn clearHourlyForecast(allocator: std.mem.Allocator, forecast: *std.ArrayList(types.HourlyForecast)) void {
for (forecast.items) |h| {
// time is now zeit.Time (no allocation to free)
allocator.free(h.condition);
}
forecast.clearRetainingCapacity();
}
fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value, coords: Coordinates) ![]types.ForecastDay {
// Group forecast data by LOCAL date (not UTC)
// This ensures day boundaries match the location's timezone
const timezone_offsets = @import("../location/timezone_offsets.zig");
const offset_minutes = timezone_offsets.getTimezoneOffset(coords);
var days: std.ArrayList(types.ForecastDay) = .empty;
errdefer days.deinit(allocator);
var current_date: ?zeit.Date = null;
var day_temps: std.ArrayList(f32) = .empty;
defer day_temps.deinit(allocator);
var day_all_hours: std.ArrayList(types.HourlyForecast) = .empty;
defer {
for (day_all_hours.items) |h| {
// time is now zeit.Time (no allocation to free)
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;
// Parse ISO 8601 timestamp and convert to local date
const utc_time = zeit.Time.fromISO8601(time_str) catch continue;
const utc_instant = utc_time.instant();
// Apply timezone offset to get local time
const abs_offset: usize = @intCast(@abs(offset_minutes));
const duration = zeit.Duration{ .minutes = abs_offset };
const local_instant = if (offset_minutes >= 0)
utc_instant.add(duration) catch continue
else
utc_instant.subtract(duration) catch continue;
const local_time = local_instant.time();
// Extract local date for grouping
const date = zeit.Date{
.year = local_time.year,
.month = local_time.month,
.day = local_time.day,
};
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);
const wind_deg: f32 = if (details_obj.object.get("wind_from_direction")) |deg|
@floatCast(deg.float)
else
0.0;
if (current_date == null or !current_date.?.eql(date)) {
// Save previous day if exists
if (current_date) |prev_date| {
if (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";
// Return all hourly forecasts - let the renderer decide which to display
const hourly_slice = try day_all_hours.toOwnedSlice(allocator);
try days.append(allocator, .{
.date = prev_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;
// Parse ISO 8601 timestamp using zeit
const parsed_time = zeit.Time.fromISO8601(time_str) catch continue;
try day_all_hours.append(allocator, .{
.time = parsed_time,
.local_time = local_time, // Already calculated above for date grouping
.temp_c = temp,
.feels_like_c = temp,
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol_code)),
.weather_code = symbolCodeToWeatherCode(symbol_code),
.wind_kph = wind_kph,
.wind_deg = wind_deg,
.precip_mm = precip,
.visibility_km = null,
});
}
// 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().?;
const Prefix = enum {
heavy,
light,
none,
};
// Check for intensity prefix
const prefix: Prefix = if (std.mem.startsWith(u8, metno_weather_code, "heavy"))
.heavy
else if (std.mem.startsWith(u8, metno_weather_code, "light"))
.light
else
.none;
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.indexOf(u8, metno_weather_code, "thunder") != null)
return switch (prefix) {
.heavy => "Heavy thunderstorm",
.light => "Light thunderstorm",
.none => "Thunderstorm",
};
if (std.mem.indexOf(u8, metno_weather_code, "rain") != null)
return switch (prefix) {
.heavy => "Heavy rain",
.light => "Light rain",
.none => "Rain",
};
if (std.mem.indexOf(u8, metno_weather_code, "sleet") != null)
return switch (prefix) {
.heavy => "Heavy sleet",
.light => "Light sleet",
.none => "Sleet",
};
if (std.mem.indexOf(u8, metno_weather_code, "snow") != null)
return switch (prefix) {
.heavy => "Heavy snow",
.light => "Light snow",
.none => "Snow",
};
return "Unknown";
}
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 test_coords = Coordinates{ .latitude = 47.6, .longitude = -122.3 }; // Seattle
const forecast = try parseForecastDays(allocator, timeseries.array.items, test_coords);
defer {
for (forecast) |day| {
allocator.free(day.condition);
for (day.hourly) |hour| {
// time is now zeit.Time (no allocation to free)
allocator.free(hour.condition);
}
allocator.free(day.hourly);
}
allocator.free(forecast);
}
try std.testing.expectEqual(@as(usize, 3), forecast.len);
// First entry is 2025-12-20T00:00:00Z, which is 2025-12-19 16:00 in Seattle (UTC-8)
// Data is now grouped by local date, so temps/conditions may differ from UTC grouping
try std.testing.expectEqual(2025, forecast[0].date.year);
try std.testing.expectEqual(zeit.Month.dec, forecast[0].date.month);
try std.testing.expectEqual(@as(u5, 19), forecast[0].date.day);
// Just verify we have valid data, don't check exact values since grouping changed
try std.testing.expect(forecast[0].max_temp_c > 0);
try std.testing.expect(forecast[0].min_temp_c > 0);
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 test_coords = Coordinates{ .latitude = 0, .longitude = 0 };
const forecast = try parseForecastDays(allocator, empty, test_coords);
defer allocator.free(forecast);
try std.testing.expectEqual(@as(usize, 0), forecast.len);
}