497 lines
22 KiB
Zig
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);
|
|
}
|