Avoid stale IMGW Hybrid current readings
This commit is contained in:
@@ -39,7 +39,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for
|
|||||||
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
|
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
|
||||||
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę pokazuj oddzielnie jako prognozę modelową preferującą IMGW ALARO i jawnie uzupełnioną przez Open-Meteo. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
|
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę pokazuj oddzielnie jako prognozę modelową preferującą IMGW ALARO i jawnie uzupełnioną przez Open-Meteo. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
|
||||||
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
|
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
|
||||||
- Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`.
|
- Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli pełny `Type_Ten_Minutes` jest o ponad 2 godziny starszy od pełnego `Type_Hour` z tej samej odpowiedzi, użyj świeższego `Type_Hour`, żeby nie nadpisywać aktualnego fallbacku starym rekordem Hybrid. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`.
|
||||||
- W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych.
|
- W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych.
|
||||||
- Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje regułowy brief dnia, najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
|
- Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje regułowy brief dnia, najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
|
||||||
- `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.
|
- `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/i
|
|||||||
|
|
||||||
## Ograniczenia API
|
## Ograniczenia API
|
||||||
|
|
||||||
Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjalny portal `meteo.imgw.pl`. Bieżące warunki są wybierane z pierwszego pełnego rekordu analizy Hybrid dla współrzędnych miejscowości, preferując rekord 10-minutowy i wymagając realnych wartości liczbowych. Dzięki temu `null` z rekordów MERGE nie jest traktowany jako pełny pomiar. Interfejs może dodatkowo pokazać rzeczywisty opad z ostatnich 10 minut, oddzielnie opisuje lokalną analizę Hybrid oraz najbliższą stację pomiarową IMGW. Pokrycie Hybrid może być częściowe: jeśli IMGW publikuje lokalny opad MERGE bez pełnego rekordu parametrów, hero zachowuje lokalny opad, a temperaturę, wiatr, wilgotność i ciśnienie jawnie uzupełnia fallbackiem ze stacji. Jeśli usługa Hybrid nie odpowiada, hero zachowuje cały pomiar `synop` jako oznaczony fallback. Gdy fallback pochodzi ze stacji oddalonej od miejscowości o co najmniej 30 km, interfejs ostrzega o możliwej różnicy warunków lokalnych. Endpoint Hybrid jest częścią publicznego frontendu IMGW, ale nie jest opisany w stabilnej dokumentacji `danepubliczne.imgw.pl`, więc integrację należy monitorować przy zmianach portalu.
|
Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjalny portal `meteo.imgw.pl`. Bieżące warunki są wybierane z pierwszego pełnego rekordu analizy Hybrid dla współrzędnych miejscowości, preferując rekord 10-minutowy i wymagając realnych wartości liczbowych. Jeśli pełny rekord 10-minutowy jest wyraźnie starszy niż pełny rekord godzinowy z tej samej odpowiedzi, aplikacja wybiera świeższy rekord godzinowy zamiast nadpisywać aktualny fallback starymi danymi. Dzięki temu `null` z rekordów MERGE nie jest traktowany jako pełny pomiar. Interfejs może dodatkowo pokazać rzeczywisty opad z ostatnich 10 minut, oddzielnie opisuje lokalną analizę Hybrid oraz najbliższą stację pomiarową IMGW. Pokrycie Hybrid może być częściowe: jeśli IMGW publikuje lokalny opad MERGE bez pełnego rekordu parametrów, hero zachowuje lokalny opad, a temperaturę, wiatr, wilgotność i ciśnienie jawnie uzupełnia fallbackiem ze stacji. Jeśli usługa Hybrid nie odpowiada, hero zachowuje cały pomiar `synop` jako oznaczony fallback. Gdy fallback pochodzi ze stacji oddalonej od miejscowości o co najmniej 30 km, interfejs ostrzega o możliwej różnicy warunków lokalnych. Endpoint Hybrid jest częścią publicznego frontendu IMGW, ale nie jest opisany w stabilnej dokumentacji `danepubliczne.imgw.pl`, więc integrację należy monitorować przy zmianach portalu.
|
||||||
|
|
||||||
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie historię odczytów. Prognoza modelowa jest wyraźnie oddzielona od pomiarów IMGW oraz bieżącej analizy Hybrid. Parametry ALARO mają pierwszeństwo w godzinach objętych tym modelem, natomiast prawdopodobieństwo opadu i dalszy horyzont pochodzą z Open-Meteo, ponieważ ALARO nie publikuje prawdopodobieństwa opadu i nie obejmuje pełnych 7 dni. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
|
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie historię odczytów. Prognoza modelowa jest wyraźnie oddzielona od pomiarów IMGW oraz bieżącej analizy Hybrid. Parametry ALARO mają pierwszeństwo w godzinach objętych tym modelem, natomiast prawdopodobieństwo opadu i dalszy horyzont pochodzą z Open-Meteo, ponieważ ALARO nie publikuje prawdopodobieństwa opadu i nie obejmuje pełnych 7 dni. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { toNumber } from "@/lib/weather-utils";
|
import { toNumber } from "@/lib/weather-utils";
|
||||||
import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current";
|
import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current";
|
||||||
|
|
||||||
|
const STALE_TEN_MINUTE_THRESHOLD_MS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
interface NormalizedHybridRow {
|
||||||
|
row: RawImgwHybridWeatherRow;
|
||||||
|
measuredAt: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
function toCelsius(value: unknown) {
|
function toCelsius(value: unknown) {
|
||||||
const temperature = toNumber(value);
|
const temperature = toNumber(value);
|
||||||
if (temperature === null) return null;
|
if (temperature === null) return null;
|
||||||
@@ -19,6 +27,13 @@ function normalizeDate(value: unknown) {
|
|||||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHybridRow(candidate: RawImgwHybridWeatherRow): NormalizedHybridRow | null {
|
||||||
|
const measuredAt = normalizeDate(candidate.Date);
|
||||||
|
if (!measuredAt) return null;
|
||||||
|
const timestamp = new Date(measuredAt).getTime();
|
||||||
|
return Number.isNaN(timestamp) ? null : { row: candidate, measuredAt, timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
function getWeatherCode(iconCode: unknown) {
|
function getWeatherCode(iconCode: unknown) {
|
||||||
if (typeof iconCode !== "string") return null;
|
if (typeof iconCode !== "string") return null;
|
||||||
const match = iconCode.match(/z(\d{2})/i);
|
const match = iconCode.match(/z(\d{2})/i);
|
||||||
@@ -48,43 +63,58 @@ function hasPrecipitationValue(candidate: RawImgwHybridWeatherRow) {
|
|||||||
return hasNumericValue(candidate.Precipitation10m) || hasNumericValue(candidate.Rain10m) || hasNumericValue(candidate.Snow10m);
|
return hasNumericValue(candidate.Precipitation10m) || hasNumericValue(candidate.Rain10m) || hasNumericValue(candidate.Snow10m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickFullWeatherRow(rows: NormalizedHybridRow[]) {
|
||||||
|
const tenMinuteRow = rows.find((candidate) => candidate.row.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate.row));
|
||||||
|
const hourlyRow = rows.find((candidate) => candidate.row.Type === "Type_Hour" && isFullWeatherRow(candidate.row));
|
||||||
|
if (!tenMinuteRow) return hourlyRow ?? null;
|
||||||
|
if (!hourlyRow) return tenMinuteRow;
|
||||||
|
return hourlyRow.timestamp - tenMinuteRow.timestamp > STALE_TEN_MINUTE_THRESHOLD_MS ? hourlyRow : tenMinuteRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPrecipitationRow(rows: NormalizedHybridRow[], fullRow: NormalizedHybridRow | null) {
|
||||||
|
if (fullRow && hasPrecipitationValue(fullRow.row)) return fullRow;
|
||||||
|
const precipitationRows = rows.filter((candidate) => candidate.row.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate.row));
|
||||||
|
if (!precipitationRows.length) return null;
|
||||||
|
if (!fullRow) return precipitationRows[0];
|
||||||
|
return precipitationRows.reduce((best, candidate) => (
|
||||||
|
Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
|
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
|
||||||
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
|
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
|
||||||
|
|
||||||
const rows = payload.data.Data
|
const rows = payload.data.Data
|
||||||
.filter((candidate): candidate is RawImgwHybridWeatherRow => {
|
.flatMap((candidate): NormalizedHybridRow[] => {
|
||||||
if (!candidate || typeof candidate !== "object") return false;
|
if (!candidate || typeof candidate !== "object") return [];
|
||||||
return (candidate.Type === "Type_Ten_Minutes" || candidate.Type === "Type_Hour") && normalizeDate(candidate.Date) !== null;
|
const row = candidate as RawImgwHybridWeatherRow;
|
||||||
})
|
if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return [];
|
||||||
.sort((left, right) => String(left.Date).localeCompare(String(right.Date)));
|
const normalizedRow = normalizeHybridRow(row);
|
||||||
const fullRow = rows.find((candidate) => candidate.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate))
|
return normalizedRow ? [normalizedRow] : [];
|
||||||
?? rows.find((candidate) => candidate.Type === "Type_Hour" && isFullWeatherRow(candidate));
|
});
|
||||||
const precipitationRow = fullRow && hasPrecipitationValue(fullRow)
|
const fullRow = pickFullWeatherRow(rows);
|
||||||
? fullRow
|
const precipitationRow = pickPrecipitationRow(rows, fullRow);
|
||||||
: rows.find((candidate) => candidate.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate));
|
|
||||||
const row = fullRow ?? precipitationRow;
|
const row = fullRow ?? precipitationRow;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
const measuredAt = normalizeDate(row.Date);
|
const precipitationSource = precipitationRow?.row ?? row.row;
|
||||||
if (!measuredAt) return null;
|
|
||||||
const precipitationSource = precipitationRow ?? row;
|
|
||||||
const rainfall10m = toNumber(precipitationSource.Rain10m);
|
const rainfall10m = toNumber(precipitationSource.Rain10m);
|
||||||
const snowfall10m = toNumber(precipitationSource.Snow10m);
|
const snowfall10m = toNumber(precipitationSource.Snow10m);
|
||||||
const weatherCode = getWeatherCode(row.Icon10);
|
const weatherCode = getWeatherCode(row.row.Icon10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
coverage: fullRow?.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
|
coverage: fullRow?.row.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
|
||||||
measuredAt,
|
measuredAt: row.measuredAt,
|
||||||
temperature: toCelsius(row.Temperature),
|
temperature: toCelsius(row.row.Temperature),
|
||||||
feelsLike: toCelsius(row.Chill),
|
feelsLike: toCelsius(row.row.Chill),
|
||||||
windSpeed: toNumber(row.Wind_Speed),
|
windSpeed: toNumber(row.row.Wind_Speed),
|
||||||
windDirection: toNumber(row.Wind_Dir),
|
windDirection: toNumber(row.row.Wind_Dir),
|
||||||
humidity: toNumber(row.Humidity),
|
humidity: toNumber(row.row.Humidity),
|
||||||
pressure: toHectopascals(row.PressureMSL),
|
pressure: toHectopascals(row.row.PressureMSL),
|
||||||
precipitation10m: toNumber(precipitationSource.Precipitation10m),
|
precipitation10m: toNumber(precipitationSource.Precipitation10m),
|
||||||
rainfall10m,
|
rainfall10m,
|
||||||
snowfall10m,
|
snowfall10m,
|
||||||
cloudCover: toNumber(row.Cloud),
|
cloudCover: toNumber(row.row.Cloud),
|
||||||
weatherCode,
|
weatherCode,
|
||||||
condition: getCondition(weatherCode, rainfall10m, snowfall10m),
|
condition: getCondition(weatherCode, rainfall10m, snowfall10m),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user