1450 lines
46 KiB
TypeScript
1450 lines
46 KiB
TypeScript
/**
|
|
* Weather Data Service
|
|
*
|
|
* Centralized, type-safe weather data fetching using the Open-Meteo SDK
|
|
* with protobuf (FlatBuffers) transport for efficient data transfer.
|
|
*
|
|
* All weather data fetching flows through this service, providing:
|
|
* - Type-safe request parameters and response structures
|
|
* - Automatic retries with exponential backoff (via the SDK)
|
|
* - Efficient binary protobuf transport instead of JSON
|
|
* - Consistent timestamp and unit handling
|
|
*/
|
|
import { Unit } from '@openmeteo/sdk/unit';
|
|
import { fetchWeatherApi } from 'openmeteo';
|
|
|
|
import { type DaylightBand, buildDaylightBands } from '$lib/charts/bands';
|
|
import * as m from '$lib/paraglide/messages';
|
|
|
|
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
|
|
import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
|
|
|
// ─── Constants ──────────────────────────────────────────────────────────────────
|
|
|
|
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
|
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
|
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
|
|
const SEASONAL_URL = 'https://seasonal-api.open-meteo.com/v1/seasonal';
|
|
|
|
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Generates an array of numbers from start (inclusive) to stop (exclusive) with the given step.
|
|
* Used to reconstruct timestamp arrays from the protobuf time/timeEnd/interval fields.
|
|
*/
|
|
export function range(start: number, stop: number, step: number): number[] {
|
|
return Array.from(
|
|
{ length: Math.max(0, Math.ceil((stop - start) / step)) },
|
|
(_, i) => start + i * step
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extracts timestamp array (in milliseconds, with UTC offset applied) from a VariablesWithTime block.
|
|
*/
|
|
export function getTimestamps(timeBlock: VariablesWithTime): number[] {
|
|
const start = Number(timeBlock.time());
|
|
const end = Number(timeBlock.timeEnd());
|
|
const interval = timeBlock.interval();
|
|
return range(start, end, interval).map((t) => t * 1000);
|
|
}
|
|
|
|
/**
|
|
* Extracts Date array (with UTC offset applied) from a VariablesWithTime block.
|
|
*/
|
|
export function getDates(timeBlock: VariablesWithTime): Date[] {
|
|
return getTimestamps(timeBlock).map((t) => new Date(t));
|
|
}
|
|
|
|
/**
|
|
* Extracts a Float32Array of values from a VariableWithValues, returning a regular number[].
|
|
* Falls back to an empty array if no values are present.
|
|
*/
|
|
export function getValues(variable: VariableWithValues): number[] {
|
|
const arr = variable.valuesArray();
|
|
if (!arr) return [];
|
|
return Array.from(arr);
|
|
}
|
|
|
|
/**
|
|
* Extracts Int64 (BigInt) values from a VariableWithValues, converting to number[].
|
|
* Used for variables stored as unix timestamps (e.g. sunrise, sunset).
|
|
*/
|
|
export function getInt64Values(variable: VariableWithValues): number[] {
|
|
const len = variable.valuesInt64Length();
|
|
const result: number[] = [];
|
|
for (let i = 0; i < len; i++) {
|
|
const val = variable.valuesInt64(i);
|
|
result.push(val !== null ? Number(val) : 0);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Converts the SDK Unit enum to a human-readable display string.
|
|
*/
|
|
export function unitToDisplayString(unit: Unit): string {
|
|
switch (unit) {
|
|
case Unit.celsius:
|
|
return '°C';
|
|
case Unit.fahrenheit:
|
|
return '°F';
|
|
case Unit.millimetre:
|
|
return 'mm';
|
|
case Unit.inch:
|
|
return 'in';
|
|
case Unit.kilometres_per_hour:
|
|
return 'km/h';
|
|
case Unit.metre_per_second:
|
|
return 'm/s';
|
|
case Unit.miles_per_hour:
|
|
return 'mph';
|
|
case Unit.knots:
|
|
return 'kn';
|
|
case Unit.percentage:
|
|
return '%';
|
|
case Unit.hectopascal:
|
|
return 'hPa';
|
|
case Unit.degree_direction:
|
|
return '°';
|
|
case Unit.wmo_code:
|
|
return 'wmo code';
|
|
case Unit.seconds:
|
|
return 's';
|
|
case Unit.hours:
|
|
return 'h';
|
|
case Unit.watt_per_square_metre:
|
|
return 'W/m²';
|
|
case Unit.megajoule_per_square_metre:
|
|
return 'MJ/m²';
|
|
case Unit.joule_per_kilogram:
|
|
return 'J/kg';
|
|
case Unit.metre:
|
|
return 'm';
|
|
case Unit.centimetre:
|
|
return 'cm';
|
|
case Unit.kilogram_per_square_metre:
|
|
return 'kg/m²';
|
|
case Unit.kilopascal:
|
|
return 'kPa';
|
|
case Unit.pascal:
|
|
return 'Pa';
|
|
case Unit.fraction:
|
|
return '';
|
|
case Unit.dimensionless:
|
|
return '';
|
|
case Unit.dimensionless_integer:
|
|
return '';
|
|
case Unit.unix_time:
|
|
return 'unixtime';
|
|
case Unit.grains_per_cubic_metre:
|
|
return 'grains/m³';
|
|
case Unit.micrograms_per_cubic_metre:
|
|
return 'µg/m³';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// ─── Shared Types ───────────────────────────────────────────────────────────────
|
|
|
|
export interface WeatherLocation {
|
|
latitude: number;
|
|
longitude: number;
|
|
timezone?: string;
|
|
}
|
|
|
|
export interface WeatherUnitParams {
|
|
temperature_unit?: 'celsius' | 'fahrenheit';
|
|
wind_speed_unit?: 'kmh' | 'ms' | 'mph' | 'kn';
|
|
precipitation_unit?: 'mm' | 'inch';
|
|
}
|
|
|
|
export type { DaylightBand };
|
|
|
|
// ─── Week Forecast Types ────────────────────────────────────────────────────────
|
|
|
|
export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams {
|
|
model?: string;
|
|
forecast_days?: number;
|
|
past_days?: number;
|
|
/** Hourly API variables to request; defaults to the full core set */
|
|
hourlyVariables?: string[];
|
|
}
|
|
|
|
export interface WeekHourlyData {
|
|
temperature_2m: number[];
|
|
precipitation: number[];
|
|
precipitation_probability: number[];
|
|
weather_code: number[];
|
|
windspeed_10m: number[];
|
|
winddirection_10m: number[];
|
|
cloud_cover: number[];
|
|
relative_humidity_2m: number[];
|
|
apparent_temperature: number[];
|
|
dew_point_2m: number[];
|
|
// Additional popular variables available for the customizable meteograms
|
|
wind_gusts_10m: number[];
|
|
pressure_msl: number[];
|
|
surface_pressure: number[];
|
|
rain: number[];
|
|
showers: number[];
|
|
snowfall: number[];
|
|
cloud_cover_low: number[];
|
|
cloud_cover_mid: number[];
|
|
cloud_cover_high: number[];
|
|
uv_index: number[];
|
|
visibility: number[];
|
|
cape: number[];
|
|
}
|
|
|
|
export interface WeekDailyData {
|
|
weather_code: number[];
|
|
temperature_2m_max: number[];
|
|
temperature_2m_min: number[];
|
|
sunrise: number[];
|
|
sunset: number[];
|
|
sunshine_duration: number[];
|
|
precipitation_sum: number[];
|
|
windspeed_10m_max: number[];
|
|
windgusts_10m_max: number[];
|
|
winddirection_10m_dominant: number[];
|
|
// Only the live forecast carries these; the archive-backed views reuse this
|
|
// shape without them, so they stay optional.
|
|
/** Seconds between sunrise and sunset. */
|
|
daylight_duration?: number[];
|
|
uv_index_max?: number[];
|
|
precipitation_probability_max?: number[];
|
|
/** Unix seconds; 0 on the days the moon doesn't rise / set at all. */
|
|
moonrise?: number[];
|
|
moonset?: number[];
|
|
/** 0 and 1 are new moon, 0.5 is full moon. */
|
|
moon_phase?: number[];
|
|
}
|
|
|
|
export interface WeekForecastResult {
|
|
hourly: WeekHourlyData;
|
|
daily: WeekDailyData;
|
|
utcOffsetSeconds: number;
|
|
timezone: string;
|
|
hourlyTimestamps: number[];
|
|
hourlyDates: Date[];
|
|
dailyDates: Date[];
|
|
daylightBands: DaylightBand[];
|
|
}
|
|
|
|
// ─── Model Comparison Types ─────────────────────────────────────────────────────
|
|
|
|
export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams {
|
|
hourlyVariables: string[];
|
|
models: string[];
|
|
}
|
|
|
|
export interface ModelSeriesData {
|
|
modelName: string;
|
|
variables: Record<string, number[]>;
|
|
}
|
|
|
|
export interface ModelCompareResult {
|
|
models: ModelSeriesData[];
|
|
timestamps: number[];
|
|
utcOffsetSeconds: number;
|
|
timezone: string;
|
|
daylightBands: DaylightBand[];
|
|
sunrise: number[];
|
|
sunset: number[];
|
|
units: Record<string, string>;
|
|
/** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */
|
|
hourlyFlat: Record<string, number[]>;
|
|
hourlyUnitsFlat: Record<string, string>;
|
|
}
|
|
|
|
// ─── Ensemble Forecast Types ────────────────────────────────────────────────────
|
|
|
|
export interface EnsembleForecastParams extends WeatherLocation, WeatherUnitParams {
|
|
hourlyVariables: string[];
|
|
models: string[];
|
|
forecast_days?: number;
|
|
}
|
|
|
|
export interface EnsembleVariableData {
|
|
members: number[][];
|
|
average: number[];
|
|
min: number[];
|
|
max: number[];
|
|
unit: string;
|
|
}
|
|
|
|
export interface EnsembleForecastResult {
|
|
variables: Record<string, EnsembleVariableData>;
|
|
timestamps: number[];
|
|
utcOffsetSeconds: number;
|
|
timezone: string;
|
|
daylightBands: DaylightBand[];
|
|
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
|
|
hourlyFlat: Record<string, number[]>;
|
|
hourlyUnitsFlat: Record<string, string>;
|
|
}
|
|
|
|
// ─── Error Humanizing ───────────────────────────────────────────────────────────
|
|
|
|
export interface FriendlyWeatherError {
|
|
/** Short, plain-language headline. */
|
|
title: string;
|
|
/** What the user can actually do about it. */
|
|
hint?: string;
|
|
/** The raw underlying message, for a collapsed "technical details" block. */
|
|
detail?: string;
|
|
}
|
|
|
|
/**
|
|
* Turns a fetch/API error into something a person can act on. The raw message
|
|
* (often API-speak like "No data is available for this location") is kept as
|
|
* `detail` so it can be shown collapsed.
|
|
*/
|
|
export function humanizeWeatherError(err: unknown): FriendlyWeatherError {
|
|
const raw = err instanceof Error ? err.message : String(err);
|
|
const msg = raw.toLowerCase();
|
|
|
|
if (
|
|
err instanceof TypeError ||
|
|
msg.includes('failed to fetch') ||
|
|
msg.includes('networkerror') ||
|
|
msg.includes('load failed') ||
|
|
msg.includes('network request failed')
|
|
) {
|
|
return {
|
|
title: m.err_network_title(),
|
|
hint: m.err_network_hint(),
|
|
detail: raw
|
|
};
|
|
}
|
|
if (
|
|
msg.includes('no data is available') ||
|
|
msg.includes('not available for this location') ||
|
|
msg.includes('out of allowed range') ||
|
|
msg.includes('coordinates')
|
|
) {
|
|
return {
|
|
title: m.err_nodata_title(),
|
|
hint: m.err_nodata_hint(),
|
|
detail: raw
|
|
};
|
|
}
|
|
if (msg.includes('invalid') || msg.includes('cannot be') || msg.includes('bad request')) {
|
|
return {
|
|
title: m.err_rejected_title(),
|
|
hint: m.err_rejected_hint(),
|
|
detail: raw
|
|
};
|
|
}
|
|
return {
|
|
title: m.err_generic_title(),
|
|
hint: m.err_generic_hint(),
|
|
detail: raw
|
|
};
|
|
}
|
|
|
|
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
|
|
|
|
// Fallback set when the caller does not specify which hourly variables it
|
|
// needs. Callers normally pass an explicit list so only shown variables are
|
|
// requested.
|
|
const WEEK_HOURLY_VARS = [
|
|
'temperature_2m',
|
|
'precipitation',
|
|
'precipitation_probability',
|
|
'weather_code',
|
|
'wind_speed_10m',
|
|
'wind_direction_10m',
|
|
'cloud_cover',
|
|
'relative_humidity_2m',
|
|
'apparent_temperature',
|
|
'dew_point_2m'
|
|
] as const;
|
|
|
|
const WEEK_DAILY_VARS = [
|
|
'weather_code',
|
|
'temperature_2m_max',
|
|
'temperature_2m_min',
|
|
'sunrise',
|
|
'sunset',
|
|
'sunshine_duration',
|
|
'precipitation_sum',
|
|
'wind_speed_10m_max',
|
|
'wind_gusts_10m_max',
|
|
'wind_direction_10m_dominant',
|
|
'daylight_duration',
|
|
'uv_index_max',
|
|
'precipitation_probability_max',
|
|
'moonrise',
|
|
'moonset',
|
|
'moon_phase'
|
|
] as const;
|
|
|
|
/**
|
|
* Assembles a WeekHourlyData structure from a name→values map, so variables that
|
|
* were not requested resolve to empty arrays. Shared by the week and historical
|
|
* fetchers (both return the same hourly shape, which the meteograms and hourly
|
|
* table consume).
|
|
*/
|
|
function weekHourlyFromByName(byName: Record<string, number[]>): WeekHourlyData {
|
|
const g = (name: string): number[] => byName[name] ?? [];
|
|
return {
|
|
temperature_2m: g('temperature_2m'),
|
|
precipitation: g('precipitation'),
|
|
precipitation_probability: g('precipitation_probability'),
|
|
weather_code: g('weather_code'),
|
|
windspeed_10m: g('wind_speed_10m'),
|
|
winddirection_10m: g('wind_direction_10m'),
|
|
cloud_cover: g('cloud_cover'),
|
|
relative_humidity_2m: g('relative_humidity_2m'),
|
|
apparent_temperature: g('apparent_temperature'),
|
|
dew_point_2m: g('dew_point_2m'),
|
|
wind_gusts_10m: g('wind_gusts_10m'),
|
|
pressure_msl: g('pressure_msl'),
|
|
surface_pressure: g('surface_pressure'),
|
|
rain: g('rain'),
|
|
showers: g('showers'),
|
|
snowfall: g('snowfall'),
|
|
cloud_cover_low: g('cloud_cover_low'),
|
|
cloud_cover_mid: g('cloud_cover_mid'),
|
|
cloud_cover_high: g('cloud_cover_high'),
|
|
uv_index: g('uv_index'),
|
|
visibility: g('visibility'),
|
|
cape: g('cape')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetches the 7-day (week) weather forecast for a single location and model.
|
|
* Returns typed hourly and daily data structures.
|
|
*/
|
|
export async function fetchWeekForecast(params: WeekForecastParams): Promise<WeekForecastResult> {
|
|
const forecastDays = params.forecast_days ?? 6;
|
|
const pastDays = params.past_days ?? 0;
|
|
const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined;
|
|
|
|
// Request only the variables the caller needs; fall back to the core set.
|
|
const hourlyVars =
|
|
params.hourlyVariables && params.hourlyVariables.length > 0
|
|
? [...new Set(params.hourlyVariables)]
|
|
: [...WEEK_HOURLY_VARS];
|
|
|
|
const apiParams: Record<string, string | number | undefined> = {
|
|
latitude: params.latitude,
|
|
longitude: params.longitude,
|
|
hourly: hourlyVars.join(','),
|
|
daily: WEEK_DAILY_VARS.join(','),
|
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
|
forecast_days: forecastDays,
|
|
past_days: pastDays,
|
|
models: modelParam,
|
|
timezone: params.timezone
|
|
};
|
|
|
|
// Remove undefined values
|
|
const cleanParams: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(apiParams)) {
|
|
if (value !== undefined) {
|
|
cleanParams[key] = String(value);
|
|
}
|
|
}
|
|
|
|
const responses = await fetchWeatherApi(FORECAST_URL, cleanParams);
|
|
const response = responses[0];
|
|
const utcOffsetSeconds = response.utcOffsetSeconds();
|
|
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
|
|
|
|
const hourlyBlock = response.hourly()!;
|
|
const dailyBlock = response.daily()!;
|
|
|
|
// Hourly: variables are in the same order as WEEK_HOURLY_VARS
|
|
const hourlyTimestamps = getTimestamps(hourlyBlock);
|
|
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
|
|
|
// Values come back in the requested order; index them by API name so
|
|
// variables that were not requested resolve to empty arrays.
|
|
const byName: Record<string, number[]> = {};
|
|
hourlyVars.forEach((name, i) => {
|
|
const variable = hourlyBlock.variables(i);
|
|
byName[name] = variable ? getValues(variable) : [];
|
|
});
|
|
|
|
const hourly = weekHourlyFromByName(byName);
|
|
|
|
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
|
const dailyDates = getDates(dailyBlock);
|
|
|
|
const sunriseVar = dailyBlock.variables(3)!;
|
|
const sunsetVar = dailyBlock.variables(4)!;
|
|
|
|
// Optional tail variables: a model that doesn't carry them yields fewer
|
|
// entries, so read them defensively instead of asserting.
|
|
const dailyAt = (i: number): number[] => {
|
|
const v = dailyBlock.variables(i);
|
|
return v ? getValues(v) : [];
|
|
};
|
|
const dailyInt64At = (i: number): number[] => {
|
|
const v = dailyBlock.variables(i);
|
|
return v ? getInt64Values(v) : [];
|
|
};
|
|
|
|
const daily: WeekDailyData = {
|
|
weather_code: getValues(dailyBlock.variables(0)!),
|
|
temperature_2m_max: getValues(dailyBlock.variables(1)!),
|
|
temperature_2m_min: getValues(dailyBlock.variables(2)!),
|
|
sunrise: getInt64Values(sunriseVar),
|
|
sunset: getInt64Values(sunsetVar),
|
|
sunshine_duration: getValues(dailyBlock.variables(5)!),
|
|
precipitation_sum: getValues(dailyBlock.variables(6)!),
|
|
windspeed_10m_max: getValues(dailyBlock.variables(7)!),
|
|
windgusts_10m_max: getValues(dailyBlock.variables(8)!),
|
|
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!),
|
|
daylight_duration: dailyAt(10),
|
|
uv_index_max: dailyAt(11),
|
|
precipitation_probability_max: dailyAt(12),
|
|
moonrise: dailyInt64At(13),
|
|
moonset: dailyInt64At(14),
|
|
moon_phase: dailyAt(15)
|
|
};
|
|
|
|
const daylightBands = buildDaylightBands(daily.sunrise, daily.sunset);
|
|
|
|
return {
|
|
hourly,
|
|
daily,
|
|
utcOffsetSeconds,
|
|
timezone,
|
|
hourlyTimestamps,
|
|
hourlyDates,
|
|
dailyDates,
|
|
daylightBands
|
|
};
|
|
}
|
|
|
|
// ─── Model Comparison Fetch ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetches forecast data for multiple models for comparison.
|
|
* Also fetches daily sunrise/sunset for daylight mark areas.
|
|
*
|
|
* Returns both a typed model array structure and a flat record structure
|
|
* compatible with existing chart utilities.
|
|
*/
|
|
export async function fetchModelComparison(
|
|
params: ModelCompareParams
|
|
): Promise<ModelCompareResult> {
|
|
const forecastApiParams: Record<string, string | number | undefined> = {
|
|
latitude: String(params.latitude),
|
|
longitude: String(params.longitude),
|
|
hourly: params.hourlyVariables.join(','),
|
|
models: params.models.join(','),
|
|
daily: 'sunrise,sunset',
|
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
|
timezone: params.timezone
|
|
};
|
|
|
|
const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams);
|
|
|
|
// With multiple models, we get one response per model
|
|
const firstResponse = responses[0];
|
|
const utcOffsetSeconds = firstResponse.utcOffsetSeconds();
|
|
const timezone = firstResponse.timezone() ?? params.timezone ?? 'UTC';
|
|
|
|
const hourlyBlock = firstResponse.hourly()!;
|
|
const timestamps = getTimestamps(hourlyBlock);
|
|
|
|
// Extract sunrise/sunset from the first response's daily block
|
|
let daylightBands: DaylightBand[] = [];
|
|
let sunrise: number[] = [];
|
|
let sunset: number[] = [];
|
|
const dailyBlock = firstResponse.daily();
|
|
if (dailyBlock) {
|
|
const sunriseVar = dailyBlock.variables(0)!;
|
|
const sunsetVar = dailyBlock.variables(1)!;
|
|
sunrise = getInt64Values(sunriseVar);
|
|
sunset = getInt64Values(sunsetVar);
|
|
daylightBands = buildDaylightBands(sunrise, sunset);
|
|
}
|
|
|
|
// Process each model's response
|
|
const models: ModelSeriesData[] = [];
|
|
const hourlyFlat: Record<string, number[]> = {};
|
|
const hourlyUnitsFlat: Record<string, string> = {};
|
|
const units: Record<string, string> = {};
|
|
|
|
// Add time to flat record
|
|
const timeInUnixSeconds = range(
|
|
Number(hourlyBlock.time()),
|
|
Number(hourlyBlock.timeEnd()),
|
|
hourlyBlock.interval()
|
|
);
|
|
hourlyFlat['time'] = timeInUnixSeconds;
|
|
|
|
for (const response of responses) {
|
|
const modelHourly = response.hourly();
|
|
if (!modelHourly) continue;
|
|
|
|
// Determine model name from the response
|
|
const modelEnum = response.model();
|
|
const modelName = modelEnumToString(modelEnum);
|
|
|
|
const modelData: ModelSeriesData = {
|
|
modelName,
|
|
variables: {}
|
|
};
|
|
|
|
for (let vi = 0; vi < params.hourlyVariables.length; vi++) {
|
|
const varName = params.hourlyVariables[vi];
|
|
const variable = modelHourly.variables(vi);
|
|
if (!variable) continue;
|
|
|
|
const values = getValues(variable);
|
|
modelData.variables[varName] = values;
|
|
|
|
// Build flat key like "temperature_2m_icon_seamless"
|
|
const flatKey = `${varName}_${modelName}`;
|
|
hourlyFlat[flatKey] = values;
|
|
|
|
// Record unit
|
|
const unitStr = unitToDisplayString(variable.unit());
|
|
units[varName] = unitStr;
|
|
hourlyUnitsFlat[flatKey] = unitStr;
|
|
}
|
|
|
|
models.push(modelData);
|
|
}
|
|
|
|
return {
|
|
models,
|
|
timestamps,
|
|
utcOffsetSeconds,
|
|
timezone,
|
|
daylightBands,
|
|
sunrise,
|
|
sunset,
|
|
units,
|
|
hourlyFlat,
|
|
hourlyUnitsFlat
|
|
};
|
|
}
|
|
|
|
// ─── Ensemble Forecast Fetch ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetches ensemble forecast data from the ensemble API.
|
|
* Separately fetches daily sunrise/sunset from the standard forecast API.
|
|
*
|
|
* Returns typed ensemble data with per-variable member arrays, averages, and spreads,
|
|
* plus a flat record structure for compatibility with existing chart utilities.
|
|
*/
|
|
export async function fetchEnsembleForecast(
|
|
params: EnsembleForecastParams
|
|
): Promise<EnsembleForecastResult> {
|
|
const forecastDays = params.forecast_days ?? 14;
|
|
|
|
const ensembleParams: Record<string, string | number | undefined> = {
|
|
latitude: String(params.latitude),
|
|
longitude: String(params.longitude),
|
|
hourly: params.hourlyVariables.join(','),
|
|
models: params.models.join(','),
|
|
forecast_days: String(forecastDays),
|
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
|
timezone: params.timezone
|
|
};
|
|
|
|
const dailyParams: Record<string, string> = {
|
|
latitude: String(params.latitude),
|
|
longitude: String(params.longitude),
|
|
daily: 'sunrise,sunset',
|
|
forecast_days: String(forecastDays),
|
|
temperature_unit: params.temperature_unit ?? 'celsius'
|
|
};
|
|
|
|
// Fetch ensemble and daily data in parallel
|
|
const [ensembleResponses, dailyResponses] = await Promise.all([
|
|
fetchWeatherApi(ENSEMBLE_URL, ensembleParams),
|
|
fetchWeatherApi(FORECAST_URL, dailyParams)
|
|
]);
|
|
|
|
const ensembleResponse = ensembleResponses[0];
|
|
const utcOffsetSeconds = ensembleResponse.utcOffsetSeconds();
|
|
const timezone = ensembleResponse.timezone() ?? params.timezone ?? 'UTC';
|
|
|
|
const hourlyBlock = ensembleResponse.hourly()!;
|
|
const timestamps = getTimestamps(hourlyBlock);
|
|
const timeLength = timestamps.length;
|
|
|
|
// Extract sunrise/sunset for daylight bands
|
|
let daylightBands: DaylightBand[] = [];
|
|
if (dailyResponses.length > 0) {
|
|
const dailyResponse = dailyResponses[0];
|
|
const dailyBlock = dailyResponse.daily();
|
|
if (dailyBlock) {
|
|
const sunrise = getInt64Values(dailyBlock.variables(0)!);
|
|
const sunset = getInt64Values(dailyBlock.variables(1)!);
|
|
daylightBands = buildDaylightBands(sunrise, sunset);
|
|
}
|
|
}
|
|
|
|
// Process ensemble variables
|
|
// Each requested variable will have multiple entries in the variables list (one per ensemble member)
|
|
const variables: Record<string, EnsembleVariableData> = {};
|
|
const hourlyFlat: Record<string, number[]> = {};
|
|
const hourlyUnitsFlat: Record<string, string> = {};
|
|
|
|
// Add time to flat record
|
|
const timeInUnixSeconds = range(
|
|
Number(hourlyBlock.time()),
|
|
Number(hourlyBlock.timeEnd()),
|
|
hourlyBlock.interval()
|
|
);
|
|
hourlyFlat['time'] = timeInUnixSeconds;
|
|
|
|
// Group variables by their requested variable name
|
|
// The SDK provides variables indexed sequentially:
|
|
// For N requested variables and M ensemble members, we get N*M variables
|
|
// ordered as: var0_member0, var0_member1, ..., var0_memberM-1, var1_member0, ...
|
|
const totalVariables = hourlyBlock.variablesLength();
|
|
const numRequestedVars = params.hourlyVariables.length;
|
|
|
|
if (totalVariables > 0 && numRequestedVars > 0) {
|
|
const membersPerVar = Math.floor(totalVariables / numRequestedVars);
|
|
|
|
for (let vi = 0; vi < numRequestedVars; vi++) {
|
|
const varName = params.hourlyVariables[vi];
|
|
const members: number[][] = [];
|
|
let unitStr = '';
|
|
|
|
for (let mi = 0; mi < membersPerVar; mi++) {
|
|
const varIdx = vi * membersPerVar + mi;
|
|
const variable = hourlyBlock.variables(varIdx);
|
|
if (!variable) continue;
|
|
|
|
const values = getValues(variable);
|
|
members.push(values);
|
|
|
|
if (mi === 0) {
|
|
unitStr = unitToDisplayString(variable.unit());
|
|
}
|
|
|
|
// Build flat key compatible with JSON API format
|
|
const memberStr = String(mi).padStart(2, '0');
|
|
const flatKey = `${varName}_member${memberStr}`;
|
|
hourlyFlat[flatKey] = values;
|
|
hourlyUnitsFlat[flatKey] = unitStr;
|
|
}
|
|
|
|
// Calculate average, min, max across members
|
|
const average = new Array<number>(timeLength).fill(0);
|
|
const min = new Array<number>(timeLength).fill(Infinity);
|
|
const max = new Array<number>(timeLength).fill(-Infinity);
|
|
|
|
for (let t = 0; t < timeLength; t++) {
|
|
let count = 0;
|
|
for (const memberValues of members) {
|
|
const val = memberValues[t];
|
|
if (val !== null && val !== undefined && !isNaN(val)) {
|
|
average[t] += val;
|
|
count++;
|
|
if (val < min[t]) min[t] = val;
|
|
if (val > max[t]) max[t] = val;
|
|
}
|
|
}
|
|
if (count > 0) {
|
|
average[t] = Math.round((average[t] / count) * 10) / 10;
|
|
}
|
|
if (min[t] === Infinity) min[t] = 0;
|
|
if (max[t] === -Infinity) max[t] = 0;
|
|
}
|
|
|
|
variables[varName] = {
|
|
members,
|
|
average,
|
|
min,
|
|
max,
|
|
unit: unitStr
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
variables,
|
|
timestamps,
|
|
utcOffsetSeconds,
|
|
timezone,
|
|
daylightBands,
|
|
hourlyFlat,
|
|
hourlyUnitsFlat
|
|
};
|
|
}
|
|
|
|
// ─── Model Enum Mapping ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Maps the SDK Model enum integer to a string model name.
|
|
* This table must stay in sync with the @openmeteo/sdk Model enum.
|
|
*/
|
|
function modelEnumToString(modelEnum: number): string {
|
|
const modelMap: Record<number, string> = {
|
|
0: 'undefined',
|
|
1: 'best_match',
|
|
2: 'gfs_seamless',
|
|
3: 'gfs_global',
|
|
4: 'gfs_hrrr',
|
|
5: 'meteofrance_seamless',
|
|
6: 'meteofrance_arpege_seamless',
|
|
7: 'meteofrance_arpege_world',
|
|
8: 'meteofrance_arpege_europe',
|
|
9: 'meteofrance_arome_seamless',
|
|
10: 'meteofrance_arome_france',
|
|
11: 'meteofrance_arome_france_hd',
|
|
12: 'jma_seamless',
|
|
13: 'jma_msm',
|
|
14: 'jms_gsm',
|
|
15: 'jma_gsm',
|
|
16: 'gem_seamless',
|
|
17: 'gem_global',
|
|
18: 'gem_regional',
|
|
19: 'gem_hrdps_continental',
|
|
20: 'icon_seamless',
|
|
21: 'icon_global',
|
|
22: 'icon_eu',
|
|
23: 'icon_d2',
|
|
24: 'ecmwf_ifs04',
|
|
25: 'metno_nordic',
|
|
26: 'era5_seamless',
|
|
27: 'era5',
|
|
28: 'cerra',
|
|
29: 'era5_land',
|
|
30: 'ecmwf_ifs',
|
|
31: 'gwam',
|
|
32: 'ewam',
|
|
33: 'glofas_seamless_v3',
|
|
34: 'glofas_forecast_v3',
|
|
35: 'glofas_consolidated_v3',
|
|
36: 'glofas_seamless_v4',
|
|
37: 'glofas_forecast_v4',
|
|
38: 'glofas_consolidated_v4',
|
|
39: 'gfs025',
|
|
40: 'gfs05',
|
|
41: 'CMCC_CM2_VHR4',
|
|
42: 'FGOALS_f3_H_highresSST',
|
|
43: 'FGOALS_f3_H',
|
|
44: 'HiRAM_SIT_HR',
|
|
45: 'MRI_AGCM3_2_S',
|
|
46: 'EC_Earth3P_HR',
|
|
47: 'MPI_ESM1_2_XR',
|
|
48: 'NICAM16_8S',
|
|
49: 'cams_europe',
|
|
50: 'cams_global',
|
|
51: 'cfsv2',
|
|
52: 'era5_ocean',
|
|
53: 'cma_grapes_global',
|
|
54: 'bom_access_global',
|
|
55: 'bom_access_global_ensemble',
|
|
56: 'arpae_cosmo_seamless',
|
|
57: 'arpae_cosmo_2i',
|
|
58: 'arpae_cosmo_2i_ruc',
|
|
59: 'arpae_cosmo_5m',
|
|
60: 'ecmwf_ifs025',
|
|
61: 'ecmwf_aifs025',
|
|
62: 'gfs013',
|
|
63: 'gfs_graphcast025',
|
|
64: 'ecmwf_wam025',
|
|
65: 'meteofrance_wave',
|
|
66: 'meteofrance_currents',
|
|
67: 'ecmwf_wam025_ensemble',
|
|
68: 'ncep_gfswave025',
|
|
69: 'ncep_gefswave025',
|
|
70: 'knmi_seamless',
|
|
71: 'knmi_harmonie_arome_europe',
|
|
72: 'knmi_harmonie_arome_netherlands',
|
|
73: 'dmi_seamless',
|
|
74: 'dmi_harmonie_arome_europe',
|
|
75: 'metno_seamless',
|
|
76: 'era5_ensemble',
|
|
77: 'ecmwf_ifs_analysis',
|
|
78: 'ecmwf_ifs_long_window',
|
|
79: 'ecmwf_ifs_analysis_long_window',
|
|
80: 'ukmo_global_deterministic_10km',
|
|
81: 'ukmo_uk_deterministic_2km',
|
|
82: 'ukmo_seamless',
|
|
83: 'ncep_gfswave016',
|
|
84: 'ncep_nbm_conus',
|
|
85: 'ukmo_global_ensemble_20km',
|
|
86: 'ecmwf_aifs025_single',
|
|
87: 'jma_jaxa_himawari',
|
|
88: 'eumetsat_sarah3',
|
|
89: 'eumetsat_lsa_saf_msg',
|
|
90: 'eumetsat_lsa_saf_iodc',
|
|
91: 'satellite_radiation_seamless',
|
|
92: 'kma_gdps',
|
|
93: 'kma_ldps',
|
|
94: 'kma_seamless',
|
|
95: 'italia_meteo_arpae_icon_2i',
|
|
96: 'ukmo_uk_ensemble_2km',
|
|
97: 'meteofrance_arome_france_hd_15min',
|
|
98: 'meteofrance_arome_france_15min',
|
|
99: 'meteoswiss_icon_ch1',
|
|
100: 'meteoswiss_icon_ch2',
|
|
101: 'meteoswiss_icon_ch1_ensemble',
|
|
102: 'meteoswiss_icon_ch2_ensemble',
|
|
103: 'meteoswiss_icon_seamless',
|
|
104: 'ncep_nam_conus',
|
|
105: 'icon_d2_ruc',
|
|
106: 'ecmwf_seas5',
|
|
107: 'ecmwf_ec46',
|
|
108: 'ecmwf_seasonal_seamless',
|
|
109: 'ecmwf_ifs_seamless',
|
|
110: 'jma_jaxa_mtg_fci',
|
|
111: 'gem_hrdps_west'
|
|
};
|
|
return modelMap[modelEnum] ?? `model_${modelEnum}`;
|
|
}
|
|
|
|
// ─── Historical (Archive) Types ─────────────────────────────────────────────
|
|
|
|
export interface HistoricalDailyData {
|
|
weather_code: number[];
|
|
temperature_2m_max: number[];
|
|
temperature_2m_min: number[];
|
|
temperature_2m_mean: number[];
|
|
apparent_temperature_max: number[];
|
|
apparent_temperature_min: number[];
|
|
sunrise: number[];
|
|
sunset: number[];
|
|
sunshine_duration: number[];
|
|
precipitation_sum: number[];
|
|
rain_sum: number[];
|
|
snowfall_sum: number[];
|
|
precipitation_hours: number[];
|
|
windspeed_10m_max: number[];
|
|
windgusts_10m_max: number[];
|
|
winddirection_10m_dominant: number[];
|
|
}
|
|
|
|
export interface HistoricalForecastParams extends WeatherLocation, WeatherUnitParams {
|
|
/** Inclusive range, YYYY-MM-DD (location-local dates). */
|
|
start_date: string;
|
|
end_date: string;
|
|
/** Hourly API variables to request; defaults to the core week set. */
|
|
hourlyVariables?: string[];
|
|
/** Reanalysis to read from; omitted lets the API pick. */
|
|
model?: string;
|
|
}
|
|
|
|
export interface HistoricalForecastResult {
|
|
hourly: WeekHourlyData;
|
|
daily: HistoricalDailyData;
|
|
utcOffsetSeconds: number;
|
|
timezone: string;
|
|
hourlyTimestamps: number[];
|
|
hourlyDates: Date[];
|
|
dailyDates: Date[];
|
|
daylightBands: DaylightBand[];
|
|
}
|
|
|
|
// Requested in this exact order; the daily block returns variables positionally.
|
|
const HISTORICAL_DAILY_VARS = [
|
|
'weather_code',
|
|
'temperature_2m_max',
|
|
'temperature_2m_min',
|
|
'temperature_2m_mean',
|
|
'apparent_temperature_max',
|
|
'apparent_temperature_min',
|
|
'sunrise',
|
|
'sunset',
|
|
'sunshine_duration',
|
|
'precipitation_sum',
|
|
'rain_sum',
|
|
'snowfall_sum',
|
|
'precipitation_hours',
|
|
'wind_speed_10m_max',
|
|
'wind_gusts_10m_max',
|
|
'wind_direction_10m_dominant'
|
|
] as const;
|
|
|
|
// ─── Historical (Archive) Fetch ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetches reanalysis (ERA5) weather for a past date range from the Open-Meteo
|
|
* archive API. Returns the same hourly shape as the week forecast (so the
|
|
* existing meteograms and hourly table render it unchanged) plus a richer daily
|
|
* block for the climate/statistics view.
|
|
*/
|
|
export async function fetchHistoricalWeather(
|
|
params: HistoricalForecastParams
|
|
): Promise<HistoricalForecastResult> {
|
|
const hourlyVars =
|
|
params.hourlyVariables && params.hourlyVariables.length > 0
|
|
? [...new Set(params.hourlyVariables)]
|
|
: [...WEEK_HOURLY_VARS];
|
|
|
|
const apiParams: Record<string, string | number | undefined> = {
|
|
latitude: params.latitude,
|
|
longitude: params.longitude,
|
|
start_date: params.start_date,
|
|
end_date: params.end_date,
|
|
hourly: hourlyVars.join(','),
|
|
daily: HISTORICAL_DAILY_VARS.join(','),
|
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
|
timezone: params.timezone,
|
|
models: params.model && params.model !== 'best_match' ? params.model : undefined
|
|
};
|
|
|
|
const cleanParams: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(apiParams)) {
|
|
if (value !== undefined) cleanParams[key] = String(value);
|
|
}
|
|
|
|
const responses = await fetchWeatherApi(ARCHIVE_URL, cleanParams);
|
|
const response = responses[0];
|
|
const utcOffsetSeconds = response.utcOffsetSeconds();
|
|
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
|
|
|
|
const hourlyBlock = response.hourly()!;
|
|
const dailyBlock = response.daily()!;
|
|
|
|
const hourlyTimestamps = getTimestamps(hourlyBlock);
|
|
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
|
|
|
const byName: Record<string, number[]> = {};
|
|
hourlyVars.forEach((name, i) => {
|
|
const variable = hourlyBlock.variables(i);
|
|
byName[name] = variable ? getValues(variable) : [];
|
|
});
|
|
const hourly = weekHourlyFromByName(byName);
|
|
|
|
// Daily variables come back in HISTORICAL_DAILY_VARS order.
|
|
const dailyDates = getDates(dailyBlock);
|
|
const d = (i: number): number[] => {
|
|
const v = dailyBlock.variables(i);
|
|
return v ? getValues(v) : [];
|
|
};
|
|
const sunrise = getInt64Values(dailyBlock.variables(6)!);
|
|
const sunset = getInt64Values(dailyBlock.variables(7)!);
|
|
|
|
const daily: HistoricalDailyData = {
|
|
weather_code: d(0),
|
|
temperature_2m_max: d(1),
|
|
temperature_2m_min: d(2),
|
|
temperature_2m_mean: d(3),
|
|
apparent_temperature_max: d(4),
|
|
apparent_temperature_min: d(5),
|
|
sunrise,
|
|
sunset,
|
|
sunshine_duration: d(8),
|
|
precipitation_sum: d(9),
|
|
rain_sum: d(10),
|
|
snowfall_sum: d(11),
|
|
precipitation_hours: d(12),
|
|
windspeed_10m_max: d(13),
|
|
windgusts_10m_max: d(14),
|
|
winddirection_10m_dominant: d(15)
|
|
};
|
|
|
|
const daylightBands = buildDaylightBands(sunrise, sunset);
|
|
|
|
return {
|
|
hourly,
|
|
daily,
|
|
utcOffsetSeconds,
|
|
timezone,
|
|
hourlyTimestamps,
|
|
hourlyDates,
|
|
dailyDates,
|
|
daylightBands
|
|
};
|
|
}
|
|
|
|
// ─── Climate Normals ────────────────────────────────────────────────────────
|
|
|
|
export interface ClimateNormals {
|
|
/** Indexed by day-of-year ordinal 1..366 (index 0 unused); NaN where no data. */
|
|
tmax: number[];
|
|
tmin: number[];
|
|
tmean: number[];
|
|
/** Mean daily precipitation (per calendar day). */
|
|
precip: number[];
|
|
baseStart: string;
|
|
baseEnd: string;
|
|
temperature_unit: string;
|
|
precipitation_unit: string;
|
|
}
|
|
|
|
export interface ClimateNormalsParams extends WeatherLocation, WeatherUnitParams {
|
|
/** Baseline period; defaults to the 1991-2020 WMO normal period. */
|
|
baseStart?: string;
|
|
baseEnd?: string;
|
|
}
|
|
|
|
// Days before the first of each month in a leap reference year, so that a
|
|
// (month, day) pair maps to a stable 1..366 ordinal regardless of leap years.
|
|
const CUM_DAYS_LEAP = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
|
|
|
/** Day-of-year ordinal (1..366) from month (1-12) and day-of-month (1-31). */
|
|
export function monthDayToOrdinal(month: number, day: number): number {
|
|
const m = Math.min(12, Math.max(1, Math.round(month)));
|
|
return CUM_DAYS_LEAP[m - 1] + day;
|
|
}
|
|
|
|
/**
|
|
* Computes daily climate normals for a location by averaging a multi-decade
|
|
* archive across years, per day-of-year, with a ±7-day smoothing window so the
|
|
* curve is stable. One archive request; used for the "vs normal" comparison.
|
|
*/
|
|
export async function fetchClimateNormals(params: ClimateNormalsParams): Promise<ClimateNormals> {
|
|
const baseStart = params.baseStart ?? '1991-01-01';
|
|
const baseEnd = params.baseEnd ?? '2020-12-31';
|
|
|
|
// UTC keeps the day-of-year bucketing exact (no offset spill across midnight);
|
|
// timezone is irrelevant to a per-calendar-day normal.
|
|
const apiParams: Record<string, string> = {
|
|
latitude: String(params.latitude),
|
|
longitude: String(params.longitude),
|
|
start_date: baseStart,
|
|
end_date: baseEnd,
|
|
daily: 'temperature_2m_max,temperature_2m_min,temperature_2m_mean,precipitation_sum',
|
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
|
timezone: 'UTC'
|
|
};
|
|
|
|
const responses = await fetchWeatherApi(ARCHIVE_URL, apiParams);
|
|
const response = responses[0];
|
|
const dailyBlock = response.daily()!;
|
|
const dates = getDates(dailyBlock);
|
|
const tmaxV = getValues(dailyBlock.variables(0)!);
|
|
const tminV = getValues(dailyBlock.variables(1)!);
|
|
const tmeanV = getValues(dailyBlock.variables(2)!);
|
|
const precipV = getValues(dailyBlock.variables(3)!);
|
|
|
|
const N = 367; // ordinals 1..366
|
|
const mk = () => ({ sum: new Array<number>(N).fill(0), cnt: new Array<number>(N).fill(0) });
|
|
const acc = { tmax: mk(), tmin: mk(), tmean: mk(), precip: mk() };
|
|
|
|
const add = (bucket: { sum: number[]; cnt: number[] }, ord: number, val: number) => {
|
|
if (Number.isFinite(val)) {
|
|
bucket.sum[ord] += val;
|
|
bucket.cnt[ord] += 1;
|
|
}
|
|
};
|
|
|
|
for (let i = 0; i < dates.length; i++) {
|
|
const dt = dates[i];
|
|
const ord = monthDayToOrdinal(dt.getUTCMonth() + 1, dt.getUTCDate());
|
|
add(acc.tmax, ord, tmaxV[i]);
|
|
add(acc.tmin, ord, tminV[i]);
|
|
add(acc.tmean, ord, tmeanV[i]);
|
|
add(acc.precip, ord, precipV[i]);
|
|
}
|
|
|
|
const mean = (bucket: { sum: number[]; cnt: number[] }): number[] =>
|
|
bucket.sum.map((s, i) => (bucket.cnt[i] > 0 ? s / bucket.cnt[i] : NaN));
|
|
|
|
// Circular ±window smoothing across the 366 ordinals (skips empty days).
|
|
const smooth = (arr: number[], window = 7): number[] => {
|
|
const out = new Array<number>(N).fill(NaN);
|
|
for (let o = 1; o <= 366; o++) {
|
|
let s = 0;
|
|
let c = 0;
|
|
for (let k = -window; k <= window; k++) {
|
|
const idx = ((o - 1 + k + 366) % 366) + 1;
|
|
const v = arr[idx];
|
|
if (Number.isFinite(v)) {
|
|
s += v;
|
|
c++;
|
|
}
|
|
}
|
|
out[o] = c > 0 ? s / c : NaN;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
return {
|
|
tmax: smooth(mean(acc.tmax)),
|
|
tmin: smooth(mean(acc.tmin)),
|
|
tmean: smooth(mean(acc.tmean)),
|
|
precip: smooth(mean(acc.precip)),
|
|
baseStart,
|
|
baseEnd,
|
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
|
precipitation_unit: params.precipitation_unit ?? 'mm'
|
|
};
|
|
}
|
|
|
|
// ─── Seasonal (Long-Range) Types ────────────────────────────────────────────
|
|
|
|
/**
|
|
* One daily variable of the seasonal ensemble: every member plus the spread
|
|
* statistics the outlook renders (percentile band, mean, extremes).
|
|
*/
|
|
export interface SeasonalVariableData {
|
|
/** Raw members, `members[m][t]`. */
|
|
members: number[][];
|
|
mean: number[];
|
|
min: number[];
|
|
max: number[];
|
|
p25: number[];
|
|
p75: number[];
|
|
unit: string;
|
|
}
|
|
|
|
export interface SeasonalForecastParams extends WeatherLocation, WeatherUnitParams {
|
|
/** Daily API variables to request; defaults to SEASONAL_DAILY_VARS. */
|
|
dailyVariables?: string[];
|
|
/** Lead time in days; the API allows at most 216. */
|
|
forecast_days?: number;
|
|
/** Seasonal model; omitted lets the API pick. */
|
|
model?: string;
|
|
}
|
|
|
|
export interface SeasonalForecastResult {
|
|
variables: Record<string, SeasonalVariableData>;
|
|
/** Milliseconds, one entry per day (already trimmed to the model's horizon). */
|
|
timestamps: number[];
|
|
/**
|
|
* Local wall time (local midnight) expressed as a UTC instant - read these
|
|
* with the UTC getters, never with the location's IANA zone. The seasonal API
|
|
* keeps ONE offset for the whole series, so a half-year range that crosses a
|
|
* DST change would otherwise land two days on the same local date.
|
|
*/
|
|
dailyDates: Date[];
|
|
/** `YYYY-MM-DD` local calendar date per day, matching the API's own labels. */
|
|
dateKeys: string[];
|
|
memberCount: number;
|
|
utcOffsetSeconds: number;
|
|
timezone: string;
|
|
}
|
|
|
|
/** The API caps the lead time here; the model itself usually stops earlier. */
|
|
export const SEASONAL_MAX_DAYS = 216;
|
|
|
|
/** Requested in this order; the daily block returns variables positionally. */
|
|
export const SEASONAL_DAILY_VARS = [
|
|
'temperature_2m_max',
|
|
'temperature_2m_min',
|
|
'temperature_2m_mean',
|
|
'precipitation_sum',
|
|
'wind_speed_10m_mean',
|
|
'cloud_cover_mean'
|
|
] as const;
|
|
|
|
// ─── Seasonal (Long-Range) Fetch ────────────────────────────────────────────
|
|
|
|
/** Linear-interpolated percentile over an already ascending array. */
|
|
function percentileSorted(sorted: number[], p: number): number {
|
|
if (sorted.length === 0) return NaN;
|
|
if (sorted.length === 1) return sorted[0];
|
|
const pos = (sorted.length - 1) * p;
|
|
const lo = Math.floor(pos);
|
|
const hi = Math.ceil(pos);
|
|
if (lo === hi) return sorted[lo];
|
|
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
|
|
}
|
|
|
|
/**
|
|
* Fetches the seasonal (multi-month) ensemble outlook from Open-Meteo's
|
|
* seasonal API. Unlike the medium-range ensemble this is daily data: each
|
|
* requested variable comes back once per member, so the members are collapsed
|
|
* into the spread statistics the outlook page plots.
|
|
*
|
|
* The requested lead time is only an upper bound - the model's own horizon is
|
|
* shorter, and every day past it comes back empty. Those trailing days are
|
|
* trimmed here so callers never plot a flat-lined tail.
|
|
*/
|
|
export async function fetchSeasonalForecast(
|
|
params: SeasonalForecastParams
|
|
): Promise<SeasonalForecastResult> {
|
|
const dailyVars =
|
|
params.dailyVariables && params.dailyVariables.length > 0
|
|
? [...new Set(params.dailyVariables)]
|
|
: [...SEASONAL_DAILY_VARS];
|
|
|
|
const apiParams: Record<string, string | number | undefined> = {
|
|
latitude: params.latitude,
|
|
longitude: params.longitude,
|
|
daily: dailyVars.join(','),
|
|
forecast_days: Math.min(params.forecast_days ?? SEASONAL_MAX_DAYS, SEASONAL_MAX_DAYS),
|
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
|
timezone: params.timezone,
|
|
models: params.model && params.model !== 'best_match' ? params.model : undefined
|
|
};
|
|
|
|
const cleanParams: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(apiParams)) {
|
|
if (value !== undefined) cleanParams[key] = String(value);
|
|
}
|
|
|
|
const responses = await fetchWeatherApi(SEASONAL_URL, cleanParams);
|
|
const response = responses[0];
|
|
const utcOffsetSeconds = response.utcOffsetSeconds();
|
|
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
|
|
|
|
const dailyBlock = response.daily()!;
|
|
const allTimestamps = getTimestamps(dailyBlock);
|
|
const timeLength = allTimestamps.length;
|
|
|
|
// Members are laid out like the ensemble API: var0_member0 … var0_memberM-1,
|
|
// var1_member0 …, so the count follows from the totals instead of being
|
|
// hard-coded (it differs per seasonal model).
|
|
const totalVariables = dailyBlock.variablesLength();
|
|
const memberCount = dailyVars.length > 0 ? Math.floor(totalVariables / dailyVars.length) : 0;
|
|
|
|
const variables: Record<string, SeasonalVariableData> = {};
|
|
|
|
for (let vi = 0; vi < dailyVars.length; vi++) {
|
|
const members: number[][] = [];
|
|
let unitStr = '';
|
|
|
|
for (let mi = 0; mi < memberCount; mi++) {
|
|
const variable = dailyBlock.variables(vi * memberCount + mi);
|
|
if (!variable) continue;
|
|
members.push(getValues(variable));
|
|
if (mi === 0) unitStr = unitToDisplayString(variable.unit());
|
|
}
|
|
|
|
const mean = new Array<number>(timeLength).fill(NaN);
|
|
const min = new Array<number>(timeLength).fill(NaN);
|
|
const max = new Array<number>(timeLength).fill(NaN);
|
|
const p25 = new Array<number>(timeLength).fill(NaN);
|
|
const p75 = new Array<number>(timeLength).fill(NaN);
|
|
|
|
for (let t = 0; t < timeLength; t++) {
|
|
const values: number[] = [];
|
|
for (const memberValues of members) {
|
|
const val = memberValues[t];
|
|
if (val != null && Number.isFinite(val)) values.push(val);
|
|
}
|
|
if (values.length === 0) continue;
|
|
values.sort((a, b) => a - b);
|
|
mean[t] = values.reduce((a, b) => a + b, 0) / values.length;
|
|
min[t] = values[0];
|
|
max[t] = values[values.length - 1];
|
|
p25[t] = percentileSorted(values, 0.25);
|
|
p75[t] = percentileSorted(values, 0.75);
|
|
}
|
|
|
|
variables[dailyVars[vi]] = { members, mean, min, max, p25, p75, unit: unitStr };
|
|
}
|
|
|
|
// Past the model's horizon every member is empty (or padded to a constant
|
|
// zero); cut the axis at the last day that carries real spread.
|
|
const sentinel = variables[dailyVars[0]];
|
|
let validLength = timeLength;
|
|
if (sentinel) {
|
|
let last = 0;
|
|
for (let t = 0; t < timeLength; t++) {
|
|
const hasSpread = !(sentinel.min[t] === 0 && sentinel.max[t] === 0);
|
|
if (Number.isFinite(sentinel.mean[t]) && hasSpread) last = t + 1;
|
|
}
|
|
validLength = last || timeLength;
|
|
}
|
|
|
|
if (validLength < timeLength) {
|
|
for (const data of Object.values(variables)) {
|
|
data.members = data.members.map((m) => m.slice(0, validLength));
|
|
data.mean = data.mean.slice(0, validLength);
|
|
data.min = data.min.slice(0, validLength);
|
|
data.max = data.max.slice(0, validLength);
|
|
data.p25 = data.p25.slice(0, validLength);
|
|
data.p75 = data.p75.slice(0, validLength);
|
|
}
|
|
}
|
|
|
|
const timestamps = allTimestamps.slice(0, validLength);
|
|
// Shifted by the response's single offset (not the IANA zone) so each day
|
|
// carries the exact local date the API labelled it with.
|
|
const dailyDates = timestamps.map((t) => new Date(t + utcOffsetSeconds * 1000));
|
|
|
|
return {
|
|
variables,
|
|
timestamps,
|
|
dailyDates,
|
|
dateKeys: dailyDates.map((d) => d.toISOString().slice(0, 10)),
|
|
memberCount,
|
|
utcOffsetSeconds,
|
|
timezone
|
|
};
|
|
}
|
|
|
|
// ─── Nearby cities snapshot ─────────────────────────────────────────────────────
|
|
|
|
export interface NearbyDaily {
|
|
/** local calendar date ("yyyy-MM-dd") -> that day's summary for this city */
|
|
byDate: Record<string, { weatherCode: number; max: number; min: number; precipitation: number }>;
|
|
}
|
|
|
|
export interface NearbySnapshotParams extends WeatherUnitParams {
|
|
points: { latitude: number; longitude: number }[];
|
|
past_days?: number;
|
|
forecast_days?: number;
|
|
}
|
|
|
|
/**
|
|
* Fetches a daily summary for several locations in one request - the forecast
|
|
* API takes comma-separated coordinates and answers with one response per
|
|
* point, in order.
|
|
*
|
|
* Deliberately runs on best_match: the nearby list can reach well past the
|
|
* domain of whatever regional model the page is showing, and a row of dashes
|
|
* is worse than a row from a model that covers everywhere.
|
|
*/
|
|
export async function fetchNearbyDaily(
|
|
params: NearbySnapshotParams
|
|
): Promise<(NearbyDaily | null)[]> {
|
|
if (params.points.length === 0) return [];
|
|
|
|
const apiParams: Record<string, string> = {
|
|
latitude: params.points.map((p) => p.latitude).join(','),
|
|
longitude: params.points.map((p) => p.longitude).join(','),
|
|
daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum',
|
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
|
past_days: String(params.past_days ?? 3),
|
|
forecast_days: String(params.forecast_days ?? 16),
|
|
timezone: 'auto'
|
|
};
|
|
|
|
const responses = await fetchWeatherApi(FORECAST_URL, apiParams);
|
|
|
|
return params.points.map((_, i) => {
|
|
const response = responses[i];
|
|
const dailyBlock = response?.daily();
|
|
if (!dailyBlock) return null;
|
|
|
|
const utcOffsetSeconds = response.utcOffsetSeconds();
|
|
const codes = getValues(dailyBlock.variables(0)!);
|
|
const max = getValues(dailyBlock.variables(1)!);
|
|
const min = getValues(dailyBlock.variables(2)!);
|
|
const precip = getValues(dailyBlock.variables(3)!);
|
|
|
|
// Same convention as the seasonal fetch: shift by the response's own
|
|
// offset, then read the calendar date off the ISO string.
|
|
const byDate: NearbyDaily['byDate'] = {};
|
|
getTimestamps(dailyBlock).forEach((t, d) => {
|
|
const key = new Date(t + utcOffsetSeconds * 1000).toISOString().slice(0, 10);
|
|
byDate[key] = {
|
|
weatherCode: codes[d],
|
|
max: max[d],
|
|
min: min[d],
|
|
precipitation: precip[d]
|
|
};
|
|
});
|
|
return { byDate };
|
|
});
|
|
}
|