feat: prefer IMGW ALARO forecast data

This commit is contained in:
zv
2026-06-02 20:23:55 +02:00
parent ad4248efdf
commit b97a1cf1ea
11 changed files with 371 additions and 69 deletions

216
lib/forecast-merge.ts Normal file
View File

@@ -0,0 +1,216 @@
import type {
DailyForecast,
ForecastSource,
HourlyForecast,
RawForecastSeries,
RawImgwForecastResponse,
RawImgwForecastRow,
RawWeatherForecast,
WeatherForecast,
} from "@/types/forecast";
const WARSAW_TIME_ZONE = "Europe/Warsaw";
const warsawHourFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: WARSAW_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
});
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function readString(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
const value = asArray(series[key])[index];
return typeof value === "string" && value ? value : null;
}
function readNumber(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
const value = asArray(series[key])[index];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function toNumber(value: unknown) {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value !== "string" || !value.trim()) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function toCelsius(value: unknown) {
const temperature = toNumber(value);
if (temperature === null) return null;
return temperature > 150 ? temperature - 273.15 : temperature;
}
function readImgwWeatherCode(value: unknown) {
if (typeof value !== "string") return null;
const match = value.match(/z(\d{2})/i);
return match ? Number(match[1]) : null;
}
function toWarsawHour(value: unknown) {
if (typeof value !== "string") return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
const parts = warsawHourFormatter.formatToParts(date);
const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? "";
return `${getPart("year")}-${getPart("month")}-${getPart("day")}T${getPart("hour")}:00`;
}
function normalizeOpenMeteoHourly(series: RawForecastSeries = {}): HourlyForecast[] {
return asArray(series.time).flatMap((_, index) => {
const time = readString(series, "time", index);
if (!time) return [];
return [{
time,
temperature: readNumber(series, "temperature_2m", index),
feelsLike: readNumber(series, "apparent_temperature", index),
precipitationProbability: readNumber(series, "precipitation_probability", index),
precipitation: readNumber(series, "precipitation", index),
weatherCode: readNumber(series, "weather_code", index),
windSpeed: readNumber(series, "wind_speed_10m", index),
source: "open-meteo" as const,
}];
});
}
function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[] {
return asArray(series.time).flatMap((_, index) => {
const date = readString(series, "time", index);
if (!date) return [];
return [{
date,
temperatureMax: readNumber(series, "temperature_2m_max", index),
temperatureMin: readNumber(series, "temperature_2m_min", index),
precipitationProbability: readNumber(series, "precipitation_probability_max", index),
precipitation: readNumber(series, "precipitation_sum", index),
weatherCode: readNumber(series, "weather_code", index),
sunrise: readString(series, "sunrise", index),
sunset: readString(series, "sunset", index),
sources: ["open-meteo" as const],
}];
});
}
function normalizeOpenMeteoForecast(raw: RawWeatherForecast): WeatherForecast {
const latitude = Number(raw.latitude);
const longitude = Number(raw.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
return {
latitude,
longitude,
timezone: typeof raw.timezone === "string" ? raw.timezone : WARSAW_TIME_ZONE,
hourly: normalizeOpenMeteoHourly(raw.hourly),
daily: normalizeOpenMeteoDaily(raw.daily),
sources: ["open-meteo"],
};
}
function normalizeImgwHourly(payload?: RawImgwForecastResponse | null) {
if (!Array.isArray(payload?.data?.Data)) return new Map<string, Partial<HourlyForecast>>();
return payload.data.Data.reduce((rows, candidate) => {
if (!candidate || typeof candidate !== "object") return rows;
const row = candidate as RawImgwForecastRow;
const time = toWarsawHour(row.Date);
if (!time) return rows;
rows.set(time, {
time,
temperature: toCelsius(row.Temperature),
feelsLike: toCelsius(row.Chill),
precipitation: toNumber(row.Precipitation),
weatherCode: readImgwWeatherCode(row.Icon),
windSpeed: toNumber(row.Wind_Speed),
source: "imgw-alaro",
});
return rows;
}, new Map<string, Partial<HourlyForecast>>());
}
function getAvailableValues(values: Array<number | null>) {
return values.filter((value): value is number => value !== null);
}
function getMinimum(values: Array<number | null>, fallback: number | null) {
const availableValues = getAvailableValues(values);
return availableValues.length ? Math.min(...availableValues) : fallback;
}
function getMaximum(values: Array<number | null>, fallback: number | null) {
const availableValues = getAvailableValues(values);
return availableValues.length ? Math.max(...availableValues) : fallback;
}
function getTotal(values: Array<number | null>, fallback: number | null) {
const availableValues = getAvailableValues(values);
return availableValues.length ? availableValues.reduce((total, value) => total + value, 0) : fallback;
}
function getWeatherCodePriority(code: number | null) {
if (code === null) return -1;
if (code >= 95) return 8;
if (code === 85 || code === 86 || (code >= 71 && code <= 77)) return 7;
if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) return 6;
if (code >= 51 && code <= 57) return 5;
if (code === 45 || code === 48) return 4;
if (code === 3) return 3;
if (code === 1 || code === 2) return 2;
if (code === 0) return 1;
return 0;
}
function getRepresentativeWeatherCode(hours: HourlyForecast[], fallback: number | null) {
if (!hours.length) return fallback;
return hours.reduce((selected, hour) => (
getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected
), null as number | null);
}
function getSources(hours: HourlyForecast[], fallback: ForecastSource[]) {
const sources = Array.from(new Set(hours.map((hour) => hour.source)));
return sources.length ? sources : fallback;
}
function summarizeDay(day: DailyForecast, hours: HourlyForecast[]): DailyForecast {
const dayHours = hours.filter((hour) => hour.time.startsWith(`${day.date}T`));
return {
...day,
temperatureMax: getMaximum(dayHours.map((hour) => hour.temperature), day.temperatureMax),
temperatureMin: getMinimum(dayHours.map((hour) => hour.temperature), day.temperatureMin),
precipitationProbability: getMaximum(dayHours.map((hour) => hour.precipitationProbability), day.precipitationProbability),
precipitation: getTotal(dayHours.map((hour) => hour.precipitation), day.precipitation),
weatherCode: getRepresentativeWeatherCode(dayHours, day.weatherCode),
sources: getSources(dayHours, day.sources),
};
}
export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null): WeatherForecast {
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload);
const imgwHours = normalizeImgwHourly(imgwPayload);
let hasImgwHours = false;
const hourly = openMeteoForecast.hourly.map((hour) => {
const imgwHour = imgwHours.get(hour.time);
if (!imgwHour) return hour;
hasImgwHours = true;
return {
...hour,
temperature: imgwHour.temperature ?? hour.temperature,
feelsLike: imgwHour.feelsLike ?? hour.feelsLike,
precipitation: imgwHour.precipitation ?? hour.precipitation,
weatherCode: imgwHour.weatherCode ?? hour.weatherCode,
windSpeed: imgwHour.windSpeed ?? hour.windSpeed,
source: "imgw-alaro" as const,
};
});
return {
...openMeteoForecast,
hourly,
daily: openMeteoForecast.daily.map((day) => summarizeDay(day, hourly)),
sources: hasImgwHours ? ["imgw-alaro", "open-meteo"] : ["open-meteo"],
};
}