/** * 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 { buildDaylightMarkAreas } from '$lib/utils/echarts'; 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'; // ─── 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 MarkArea = [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]; // ─── Week Forecast Types ──────────────────────────────────────────────────────── export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams { model?: string; forecast_days?: number; past_days?: number; } 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[]; } 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[]; } export interface WeekForecastResult { hourly: WeekHourlyData; daily: WeekDailyData; utcOffsetSeconds: number; timezone: string; hourlyTimestamps: number[]; hourlyDates: Date[]; dailyDates: Date[]; markAreas: MarkArea[]; } // ─── Model Comparison Types ───────────────────────────────────────────────────── export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams { hourlyVariables: string[]; models: string[]; } export interface ModelSeriesData { modelName: string; variables: Record; } export interface ModelCompareResult { models: ModelSeriesData[]; timestamps: number[]; utcOffsetSeconds: number; timezone: string; markAreas: MarkArea[]; units: Record; /** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */ hourlyFlat: Record; hourlyUnitsFlat: Record; } // ─── 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; timestamps: number[]; utcOffsetSeconds: number; timezone: string; markAreas: MarkArea[]; /** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */ hourlyFlat: Record; hourlyUnitsFlat: Record; } // ─── Week Forecast Fetch ──────────────────────────────────────────────────────── 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' ] as const; /** * 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 { const forecastDays = params.forecast_days ?? 6; const pastDays = params.past_days ?? 0; const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined; const apiParams: Record = { latitude: params.latitude, longitude: params.longitude, hourly: WEEK_HOURLY_VARS.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 = {}; 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)); const hourly: WeekHourlyData = { temperature_2m: getValues(hourlyBlock.variables(0)!), precipitation: getValues(hourlyBlock.variables(1)!), precipitation_probability: getValues(hourlyBlock.variables(2)!), weather_code: getValues(hourlyBlock.variables(3)!), windspeed_10m: getValues(hourlyBlock.variables(4)!), winddirection_10m: getValues(hourlyBlock.variables(5)!), cloud_cover: getValues(hourlyBlock.variables(6)!), relative_humidity_2m: getValues(hourlyBlock.variables(7)!), apparent_temperature: getValues(hourlyBlock.variables(8)!), dew_point_2m: getValues(hourlyBlock.variables(9)!) }; // 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)!; 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)!) }; const markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset); return { hourly, daily, utcOffsetSeconds, timezone, hourlyTimestamps, hourlyDates, dailyDates, markAreas }; } // ─── 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 { const forecastApiParams: Record = { 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 markAreas: MarkArea[] = []; const dailyBlock = firstResponse.daily(); if (dailyBlock) { const sunriseVar = dailyBlock.variables(0)!; const sunsetVar = dailyBlock.variables(1)!; const sunrise = getInt64Values(sunriseVar); const sunset = getInt64Values(sunsetVar); markAreas = buildDaylightMarkAreas(sunrise, sunset); } // Process each model's response const models: ModelSeriesData[] = []; const hourlyFlat: Record = {}; const hourlyUnitsFlat: Record = {}; const units: Record = {}; // 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, markAreas, 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 { const forecastDays = params.forecast_days ?? 14; const ensembleParams: Record = { 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 = { 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 mark areas let markAreas: MarkArea[] = []; 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)!); markAreas = buildDaylightMarkAreas(sunrise, sunset); } } // Process ensemble variables // Each requested variable will have multiple entries in the variables list (one per ensemble member) const variables: Record = {}; const hourlyFlat: Record = {}; const hourlyUnitsFlat: Record = {}; // 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(timeLength).fill(0); const min = new Array(timeLength).fill(Infinity); const max = new Array(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, markAreas, 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 = { 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}`; }