diff --git a/defaultmodules/utils.js b/defaultmodules/utils.js index d9eab5c57e..ecdb890239 100644 --- a/defaultmodules/utils.js +++ b/defaultmodules/utils.js @@ -1,154 +1,3 @@ -/** - * A function to make HTTP requests via the server to avoid CORS-errors. - * @param {string} url the url to fetch from - * @param {string} type what content-type to expect in the response, can be "json" or "xml" - * @param {boolean} useCorsProxy A flag to indicate - * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send - * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive - * @param {string} basePath The base path, default is "/" - * @returns {Promise} resolved when the fetch is done. The response headers is placed in a headers-property (provided the response does not already contain a headers-property). - */ -async function performWebRequest (url, type = "json", useCorsProxy = false, requestHeaders = undefined, expectedResponseHeaders = undefined, basePath = "/") { - const request = {}; - let requestUrl; - if (useCorsProxy) { - requestUrl = getCorsUrl(url, requestHeaders, expectedResponseHeaders, basePath); - } else { - requestUrl = url; - request.headers = getHeadersToSend(requestHeaders); - } - - try { - const response = await fetch(requestUrl, request); - if (response.ok) { - const data = await response.text(); - - if (type === "xml") { - return new DOMParser().parseFromString(data, "text/html"); - } else { - if (!data || !data.length > 0) return undefined; - - const dataResponse = JSON.parse(data); - if (!dataResponse.headers) { - dataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response); - } - return dataResponse; - } - } else { - throw new Error(`Response status: ${response.status}`); - } - } catch (error) { - Log.error(`Error fetching data from ${url}: ${error}`); - return undefined; - } -} - -/** - * Gets a URL that will be used when calling the CORS-method on the server. - * @param {string} url the url to fetch from - * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send - * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive - * @param {string} basePath The base path, default is "/" - * @returns {string} to be used as URL when calling CORS-method on server. - */ -const getCorsUrl = function (url, requestHeaders, expectedResponseHeaders, basePath = "/") { - if (!url || url.length < 1) { - throw new Error(`Invalid URL: ${url}`); - } else { - let corsUrl = `${location.protocol}//${location.host}${basePath}cors?`; - - const requestHeaderString = getRequestHeaderString(requestHeaders); - if (requestHeaderString) corsUrl = `${corsUrl}sendheaders=${requestHeaderString}`; - - const expectedResponseHeadersString = getExpectedResponseHeadersString(expectedResponseHeaders); - if (requestHeaderString && expectedResponseHeadersString) { - corsUrl = `${corsUrl}&expectedheaders=${expectedResponseHeadersString}`; - } else if (expectedResponseHeadersString) { - corsUrl = `${corsUrl}expectedheaders=${expectedResponseHeadersString}`; - } - - if (requestHeaderString || expectedResponseHeadersString) { - return `${corsUrl}&url=${url}`; - } - return `${corsUrl}url=${url}`; - } -}; - -/** - * Gets the part of the CORS URL that represents the HTTP headers to send. - * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send - * @returns {string} to be used as request-headers component in CORS URL. - */ -const getRequestHeaderString = function (requestHeaders) { - let requestHeaderString = ""; - if (requestHeaders) { - for (const header of requestHeaders) { - if (requestHeaderString.length === 0) { - requestHeaderString = `${header.name}:${encodeURIComponent(header.value)}`; - } else { - requestHeaderString = `${requestHeaderString},${header.name}:${encodeURIComponent(header.value)}`; - } - } - return requestHeaderString; - } - return undefined; -}; - -/** - * Gets headers and values to attach to the web request. - * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send - * @returns {object} An object specifying name and value of the headers. - */ -const getHeadersToSend = (requestHeaders) => { - const headersToSend = {}; - if (requestHeaders) { - for (const header of requestHeaders) { - headersToSend[header.name] = header.value; - } - } - - return headersToSend; -}; - -/** - * Gets the part of the CORS URL that represents the expected HTTP headers to receive. - * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive - * @returns {string} to be used as the expected HTTP-headers component in CORS URL. - */ -const getExpectedResponseHeadersString = function (expectedResponseHeaders) { - let expectedResponseHeadersString = ""; - if (expectedResponseHeaders) { - for (const header of expectedResponseHeaders) { - if (expectedResponseHeadersString.length === 0) { - expectedResponseHeadersString = `${header}`; - } else { - expectedResponseHeadersString = `${expectedResponseHeadersString},${header}`; - } - } - return expectedResponseHeaders; - } - return undefined; -}; - -/** - * Gets the values for the expected headers from the response. - * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive - * @param {Response} response the HTTP response - * @returns {string} to be used as the expected HTTP-headers component in CORS URL. - */ -const getHeadersFromResponse = (expectedResponseHeaders, response) => { - const responseHeaders = []; - - if (expectedResponseHeaders) { - for (const header of expectedResponseHeaders) { - const headerValue = response.headers.get(header); - responseHeaders.push({ name: header, value: headerValue }); - } - } - - return responseHeaders; -}; - /** * Format the time according to the config * @param {object} config The config of the module @@ -178,6 +27,5 @@ const formatTime = (config, time) => { }; if (typeof module !== "undefined") module.exports = { - performWebRequest, formatTime }; diff --git a/defaultmodules/weather/current.njk b/defaultmodules/weather/current.njk index 51687231eb..b75966d3b1 100644 --- a/defaultmodules/weather/current.njk +++ b/defaultmodules/weather/current.njk @@ -25,7 +25,7 @@ {% if config.showHumidity === "wind" %} {{ humidity() }} {% endif %} - {% if config.showSun %} + {% if config.showSun and current.nextSunAction() %} {% if current.nextSunAction() === "sunset" %} diff --git a/defaultmodules/weather/node_helper.js b/defaultmodules/weather/node_helper.js new file mode 100644 index 0000000000..f9ea08c42a --- /dev/null +++ b/defaultmodules/weather/node_helper.js @@ -0,0 +1,103 @@ +const path = require("node:path"); +const NodeHelper = require("node_helper"); +const Log = require("logger"); + +module.exports = NodeHelper.create({ + providers: {}, + + start () { + Log.log(`Starting node helper for: ${this.name}`); + }, + + socketNotificationReceived (notification, payload) { + if (notification === "INIT_WEATHER") { + Log.log(`Received INIT_WEATHER for instance ${payload.instanceId}`); + this.initWeatherProvider(payload); + } else if (notification === "STOP_WEATHER") { + Log.log(`Received STOP_WEATHER for instance ${payload.instanceId}`); + this.stopWeatherProvider(payload.instanceId); + } + // FETCH_WEATHER is no longer needed - HTTPFetcher handles periodic fetching + }, + + /** + * Initialize a weather provider + * @param {object} config The configuration object + */ + async initWeatherProvider (config) { + const identifier = config.weatherProvider.toLowerCase(); + const instanceId = config.instanceId; + + Log.log(`Attempting to initialize provider ${identifier} for instance ${instanceId}`); + + if (this.providers[instanceId]) { + Log.log(`Weather provider ${identifier} already initialized for instance ${instanceId}`); + return; + } + + try { + // Dynamically load the provider module + const providerPath = path.join(__dirname, "providers", `${identifier}.js`); + Log.log(`Loading provider from: ${providerPath}`); + const ProviderClass = require(providerPath); + + // Create provider instance + const provider = new ProviderClass(config); + + // Set up callbacks before initializing + provider.setCallbacks( + (data) => { + // On data received + this.sendSocketNotification("WEATHER_DATA", { + instanceId, + type: config.type, + data + }); + }, + (errorInfo) => { + // On error + this.sendSocketNotification("WEATHER_ERROR", { + instanceId, + error: errorInfo.message || "Unknown error", + translationKey: errorInfo.translationKey + }); + } + ); + + await provider.initialize(); + this.providers[instanceId] = provider; + + this.sendSocketNotification("WEATHER_INITIALIZED", { + instanceId, + locationName: provider.locationName + }); + + // Start periodic fetching + provider.start(); + + Log.log(`Weather provider ${identifier} initialized for instance ${instanceId}`); + } catch (error) { + Log.error(`Failed to initialize weather provider ${identifier}:`, error); + this.sendSocketNotification("WEATHER_ERROR", { + instanceId, + error: error.message + }); + } + }, + + /** + * Stop and cleanup a weather provider + * @param {string} instanceId The instance identifier + */ + stopWeatherProvider (instanceId) { + const provider = this.providers[instanceId]; + + if (provider) { + Log.log(`Stopping weather provider for instance ${instanceId}`); + provider.stop(); + delete this.providers[instanceId]; + } else { + Log.warn(`No provider found for instance ${instanceId}`); + } + } +}); diff --git a/defaultmodules/weather/provider-utils.js b/defaultmodules/weather/provider-utils.js new file mode 100644 index 0000000000..83d1e44fc6 --- /dev/null +++ b/defaultmodules/weather/provider-utils.js @@ -0,0 +1,153 @@ +/** + * Shared utility functions for weather providers + */ + +const SunCalc = require("suncalc"); + +/** + * Convert OpenWeatherMap icon codes to internal weather types + * @param {string} weatherType - OpenWeatherMap icon code (e.g., "01d", "02n") + * @returns {string|null} Internal weather type + */ +function convertWeatherType (weatherType) { + const weatherTypes = { + "01d": "day-sunny", + "02d": "day-cloudy", + "03d": "cloudy", + "04d": "cloudy-windy", + "09d": "showers", + "10d": "rain", + "11d": "thunderstorm", + "13d": "snow", + "50d": "fog", + "01n": "night-clear", + "02n": "night-cloudy", + "03n": "night-cloudy", + "04n": "night-cloudy", + "09n": "night-showers", + "10n": "night-rain", + "11n": "night-thunderstorm", + "13n": "night-snow", + "50n": "night-alt-cloudy-windy" + }; + + return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; +} + +/** + * Apply timezone offset to a date + * @param {Date} date - The date to apply offset to + * @param {number} offsetMinutes - Timezone offset in minutes + * @returns {Date} Date with applied offset + */ +function applyTimezoneOffset (date, offsetMinutes) { + const utcTime = date.getTime() + (date.getTimezoneOffset() * 60000); + return new Date(utcTime + (offsetMinutes * 60000)); +} + +/** + * Limit decimal places for coordinates (truncate, not round) + * @param {number} value - The coordinate value + * @param {number} decimals - Maximum number of decimal places + * @returns {number} Value with limited decimal places + */ +function limitDecimals (value, decimals) { + const str = value.toString(); + if (str.includes(".")) { + const parts = str.split("."); + if (parts[1].length > decimals) { + return parseFloat(`${parts[0]}.${parts[1].substring(0, decimals)}`); + } + } + return value; +} + +/** + * Get sunrise and sunset times for a given date and location + * @param {Date} date - The date to calculate for + * @param {number} lat - Latitude + * @param {number} lon - Longitude + * @returns {object} Object with sunrise and sunset Date objects + */ +function getSunTimes (date, lat, lon) { + const sunTimes = SunCalc.getTimes(date, lat, lon); + return { + sunrise: sunTimes.sunrise, + sunset: sunTimes.sunset + }; +} + +/** + * Check if a given time is during daylight hours + * @param {Date} date - The date/time to check + * @param {Date} sunrise - Sunrise time + * @param {Date} sunset - Sunset time + * @returns {boolean} True if during daylight hours + */ +function isDayTime (date, sunrise, sunset) { + if (!sunrise || !sunset) { + return true; // Default to day if times unavailable + } + return date >= sunrise && date < sunset; +} + +/** + * Format timezone offset as string (e.g., "+01:00", "-05:30") + * @param {number} offsetMinutes - Timezone offset in minutes (use -new Date().getTimezoneOffset() for local) + * @returns {string} Formatted offset string + */ +function formatTimezoneOffset (offsetMinutes) { + const hours = Math.floor(Math.abs(offsetMinutes) / 60); + const minutes = Math.abs(offsetMinutes) % 60; + const sign = offsetMinutes >= 0 ? "+" : "-"; + return `${sign}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; +} + +/** + * Get date string in YYYY-MM-DD format (local time) + * @param {Date} date - The date to format + * @returns {string} Date string in YYYY-MM-DD format + */ +function getDateString (date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** + * Convert wind speed from km/h to m/s + * @param {number} kmh - Wind speed in km/h + * @returns {number} Wind speed in m/s + */ +function convertKmhToMs (kmh) { + return kmh / 3.6; +} + +/** + * Validate and limit coordinate precision + * @param {object} config - Configuration object with lat/lon properties + * @param {number} maxDecimals - Maximum decimal places to preserve + * @throws {Error} If coordinates are missing or invalid + */ +function validateCoordinates (config, maxDecimals = 4) { + if (config.lat == null || config.lon == null + || !Number.isFinite(config.lat) || !Number.isFinite(config.lon)) { + throw new Error("Latitude and longitude are required"); + } + + config.lat = limitDecimals(config.lat, maxDecimals); + config.lon = limitDecimals(config.lon, maxDecimals); +} + +module.exports = { + convertWeatherType, + applyTimezoneOffset, + limitDecimals, + getSunTimes, + isDayTime, + formatTimezoneOffset, + getDateString, + convertKmhToMs, + validateCoordinates +}; diff --git a/defaultmodules/weather/providers/README.md b/defaultmodules/weather/providers/README.md index faa60a058a..62959756d4 100644 --- a/defaultmodules/weather/providers/README.md +++ b/defaultmodules/weather/providers/README.md @@ -1,3 +1,3 @@ # Weather Module Weather Provider Development Documentation -For how to develop your own weather provider, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/development/weather-provider.html). +For how to develop your own weather provider, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/module-development/weather-provider.html). diff --git a/defaultmodules/weather/providers/envcanada.js b/defaultmodules/weather/providers/envcanada.js index d3d7cd5d67..a50a812143 100644 --- a/defaultmodules/weather/providers/envcanada.js +++ b/defaultmodules/weather/providers/envcanada.js @@ -1,579 +1,408 @@ -/* global WeatherProvider, WeatherObject, WeatherUtils */ +const Log = require("logger"); +const { convertKmhToMs } = require("../provider-utils"); +const HTTPFetcher = require("#http_fetcher"); -/* - * This class is a provider for Environment Canada MSC Datamart - * Note that this is only for Canadian locations and does not require an API key (access is anonymous) +/** + * Server-side weather provider for Environment Canada MSC Datamart + * Canada only, no API key required (anonymous access) * - * EC Documentation at following links: - * https://dd.weather.gc.ca/citypage_weather/schema/ - * https://eccc-msc.github.io/open-data/msc-datamart/readme_en/ + * Documentation: + * https://dd.weather.gc.ca/citypage_weather/schema/ + * https://eccc-msc.github.io/open-data/msc-datamart/readme_en/ * - * This module supports Canadian locations only and requires 2 additional config parameters: - * - * siteCode - the city/town unique identifier for which weather is to be displayed. Format is 's0000000'. - * - * provCode - the 2-character province code for the selected city/town. - * - * Example: for Toronto, Ontario, the following parameters would be used - * - * siteCode: 's0000458', - * provCode: 'ON' - * - * To determine the siteCode and provCode values for a Canadian city/town, look at the Environment Canada document - * at https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv (or site_list_fr.csv). There you will find a table - * with locations you can search under column B (English Names), with the corresponding siteCode under - * column A (Codes) and provCode under column C (Province). - * - * Acknowledgement: Some logic and code for parsing Environment Canada web pages is based on material from MMM-EnvCanada - * - * License to use Environment Canada (EC) data is detailed here: - * https://eccc-msc.github.io/open-data/licence/readme_en/ + * Requires siteCode and provCode config parameters + * See https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv */ -WeatherProvider.register("envcanada", { - // Set the name of the provider for debugging and alerting purposes (eg. provide eye-catcher) - providerName: "Environment Canada", - - // Set the default config properties that is specific to this provider - defaults: { - useCorsProxy: true, - siteCode: "s1234567", - provCode: "ON" - }, - - /* - * Set config values (equates to weather module config values). Also set values pertaining to caching of - * Today's temperature forecast (for use in the Forecast functions below) - */ - setConfig (config) { - this.config = config; - - this.todayTempCacheMin = 0; - this.todayTempCacheMax = 0; - this.todayCached = false; - this.cacheCurrentTemp = 999; - this.lastCityPageCurrent = " "; - this.lastCityPageForecast = " "; - this.lastCityPageHourly = " "; - }, - - /* - * Called when the weather provider is started - */ - start () { - Log.info(`[weatherprovider.envcanada] ${this.providerName} started.`); - this.setFetchedLocation(this.config.location); - }, - - /* - * Override the fetchCurrentWeather method to query EC and construct a Current weather object - */ - fetchCurrentWeather () { - this.fetchCommon("Current"); - }, - - /* - * Override the fetchWeatherForecast method to query EC and construct Forecast/Daily weather objects - */ - fetchWeatherForecast () { - - this.fetchCommon("Forecast"); - - }, - - /* - * Override the fetchWeatherHourly method to query EC and construct Hourly weather objects - */ - fetchWeatherHourly () { - this.fetchCommon("Hourly"); - }, - - /* - * Because the process to fetch weather data is virtually the same for Current, Forecast/Daily, and Hourly weather, - * a common module is used to access the EC weather data. The only customization (based on the caller of this routine) - * is how the data will be parsed to satisfy the Weather module config in Config.js - * - * Accessing EC weather data is accomplished in 2 steps: - * - * 1. Query the MSC Datamart Index page, which returns a list of all the filenames for all the cities that have - * weather data currently available. - * - * 2. With the city filename identified, build the appropriate URL and get the weather data (XML document) for the - * city specified in the Weather module Config information - */ - fetchCommon (target) { - const forecastURL = this.getUrl(); // Get the appropriate URL for the MSC Datamart Index page - - Log.debug(`[weatherprovider.envcanada] ${target} Index url: ${forecastURL}`); - - this.fetchData(forecastURL, "xml") // Query the Index page URL - .then((indexData) => { - if (!indexData) { - // Did not receive usable new data. - Log.info(`[weatherprovider.envcanada] ${target} - did not receive usable index data`); - this.updateAvailable(); // If there were issues, update anyways to reset timer - return; - } +class EnvCanadaProvider { + constructor (config) { + this.config = { + siteCode: "s0000000", + provCode: "ON", + type: "current", + updateInterval: 10 * 60 * 1000, + ...config + }; - /** - * With the Index page read, we must locate the filename/link for the specified city (aka Sitecode). - * This is done by building the city filename and searching for it on the Index page. Once found, - * extract the full filename (a unique name that includes dat/time, filename, etc.) and then add it - * to the Index page URL to create the proper URL pointing to the city's weather data. Finally, read the - * URL to pull in the city's XML document so that weather data can be parsed and displayed. - */ - - let forecastFile = ""; - let forecastFileURL = ""; - const fileSuffix = `${this.config.siteCode}_en.xml`; // Build city filename - const nextFile = indexData.body.innerHTML.split(fileSuffix); // Find filename on Index page - - if (nextFile.length > 1) { // Parse out the full unique file city filename - // Find the last occurrence - forecastFile = nextFile[nextFile.length - 2].slice(-41) + fileSuffix; - forecastFileURL = forecastURL + forecastFile; // Create full URL to the city's weather data - } + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + this.lastCityPageURL = null; + this.cacheCurrentTemp = null; + this.currentHour = null; // Track current hour for URL updates + } + + async initialize () { + this.#validateConfig(); + this.#initializeFetcher(); + } - Log.debug(`[weatherprovider.envcanada] ${target} Citypage url: ${forecastFileURL}`); + setCallbacks (onData, onError) { + this.onDataCallback = onData; + this.onErrorCallback = onError; + } + + start () { + if (this.fetcher) { + this.fetcher.startPeriodicFetch(); + } + } + + stop () { + if (this.fetcher) { + this.fetcher.clearTimer(); + } + } - /* - * If the Citypage filename has not changed since the last Weather refresh, the forecast has not changed and - * and therefore we can skip reading the Citypage URL. - */ + #validateConfig () { + if (!this.config.siteCode || !this.config.provCode) { + throw new Error("siteCode and provCode are required"); + } + } - if (target === "Current" && this.lastCityPageCurrent === forecastFileURL) { - Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`); - this.updateAvailable(); // Update anyways to reset refresh timer + #initializeFetcher () { + this.currentHour = new Date().toISOString().substring(11, 13); + const indexURL = this.#getIndexUrl(); + + this.fetcher = new HTTPFetcher(indexURL, { + reloadInterval: this.config.updateInterval, + logContext: "weatherprovider.envcanada" + }); + + this.fetcher.on("response", async (response) => { + try { + // Check if hour changed - restart fetcher with new URL + const newHour = new Date().toISOString().substring(11, 13); + if (newHour !== this.currentHour) { + Log.info("[envcanada] Hour changed, reinitializing fetcher"); + this.stop(); + this.#initializeFetcher(); + this.start(); return; } - if (target === "Forecast" && this.lastCityPageForecast === forecastFileURL) { - Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`); - this.updateAvailable(); // Update anyways to reset refresh timer + const html = await response.text(); + const cityPageURL = this.#extractCityPageURL(html); + + if (!cityPageURL) { + // This can happen during hour transitions when old responses arrive + Log.debug("[envcanada] Could not find city page URL (may be stale response from previous hour)"); return; } - if (target === "Hourly" && this.lastCityPageHourly === forecastFileURL) { - Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`); - this.updateAvailable(); // Update anyways to reset refresh timer + if (cityPageURL === this.lastCityPageURL) { + Log.debug("[envcanada] City page unchanged"); return; } - this.fetchData(forecastFileURL, "xml") // Read city's URL to get weather data - .then((cityData) => { - if (!cityData) { - // Did not receive usable new data. - Log.info(`[weatherprovider.envcanada] ${target} - did not receive usable citypage data`); - return; - } - - /* - * With the city's weather data read, parse the resulting XML document for the appropriate weather data - * elements to create a weather object. Next, set Weather modules details from that object. - */ - Log.debug(`[weatherprovider.envcanada] ${target} - Citypage has been read and will be processed for updates`); - - if (target === "Current") { - const currentWeather = this.generateWeatherObjectFromCurrentWeather(cityData); - this.setCurrentWeather(currentWeather); - this.lastCityPageCurrent = forecastFileURL; - } - - if (target === "Forecast") { - const forecastWeather = this.generateWeatherObjectsFromForecast(cityData); - this.setWeatherForecast(forecastWeather); - this.lastCityPageForecast = forecastFileURL; - } - - if (target === "Hourly") { - const hourlyWeather = this.generateWeatherObjectsFromHourly(cityData); - this.setWeatherHourly(hourlyWeather); - this.lastCityPageHourly = forecastFileURL; - } - }) - .catch(function (cityRequest) { - Log.info(`[weatherprovider.envcanada] ${target} - could not load citypage data from: ${forecastFileURL}`); - }) - .finally(() => this.updateAvailable()); // Update no matter what to reset weather refresh timer - }) - .catch(function (indexRequest) { - Log.error(`[weatherprovider.envcanada] ${target} - could not load index data ... `, indexRequest); - this.updateAvailable(); // If there were issues, update anyways to reset timer - }); - }, - - /* - * Build the EC Index page URL based on current GMT hour. The Index page will provide a list of links for each city - * that will, in turn, provide actual weather data. The URL is comprised of 3 parts: - * - * Fixed value + Prov code specified in Weather module Config.js + current hour as GMT - */ - getUrl () { - let forecastURL = `https://dd.weather.gc.ca/today/citypage_weather/${this.config.provCode}`; - const hour = this.getCurrentHourGMT(); - forecastURL += `/${hour}/`; - return forecastURL; - }, - - /* - * Get current hour-of-day in GMT context - */ - getCurrentHourGMT () { - const now = new Date(); - return now.toISOString().substring(11, 13); // "HH" in GMT - }, - - /* - * Generate a WeatherObject based on current EC weather conditions - */ - generateWeatherObjectFromCurrentWeather (ECdoc) { - const currentWeather = new WeatherObject(); - - /* - * There are instances where EC will update weather data and current temperature will not be - * provided. While this is a defect in the EC systems, we need to accommodate to avoid a current temp - * of NaN being displayed. Therefore... whenever we get a valid current temp from EC, we will cache - * the value. Whenever EC data is missing current temp, we will provide the cached value - * instead. This is reasonable since the cached value will typically be accurate within the previous - * hour. The only time this does not work as expected is when MM is restarted and the first query to - * EC finds no current temp. In this scenario, MM will end up displaying a current temp of null; - */ - if (ECdoc.querySelector("siteData currentConditions temperature").textContent) { - currentWeather.temperature = ECdoc.querySelector("siteData currentConditions temperature").textContent; - this.cacheCurrentTemp = currentWeather.temperature; - } else { - currentWeather.temperature = this.cacheCurrentTemp; - } - - if (ECdoc.querySelector("siteData currentConditions wind speed").textContent === "calm") { - currentWeather.windSpeed = "0"; - } else { - currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent); - } + this.lastCityPageURL = cityPageURL; + await this.#fetchCityPage(cityPageURL); - currentWeather.windFromDirection = ECdoc.querySelector("siteData currentConditions wind bearing").textContent; + } catch (error) { + Log.error("[envcanada] Error:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: error.message, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + } + }); - currentWeather.humidity = ECdoc.querySelector("siteData currentConditions relativeHumidity").textContent; + this.fetcher.on("error", (errorInfo) => { + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); + } + }); + } - /* - * Ensure showPrecipitationAmount is forced to false. EC does not really provide POP for current day - * and this feature for the weather module (current only) is sort of broken in that it wants - * to say POP but will display precip as an accumulated amount vs. a percentage. - */ - this.config.showPrecipitationAmount = false; + async #fetchCityPage (url) { + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); - /* - * If the module config wants to showFeelsLike... default to the current temperature. - * Check for EC wind chill and humidex values and overwrite the feelsLikeTemp value. - * This assumes that the EC current conditions will never contain both a wind chill - * and humidex temperature. - */ - if (this.config.showFeelsLike) { - currentWeather.feelsLikeTemp = currentWeather.temperature; + const xml = await response.text(); + const weatherData = this.#parseWeatherData(xml); - if (ECdoc.querySelector("siteData currentConditions windChill")) { - currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions windChill").textContent; + if (this.onDataCallback) { + this.onDataCallback(weatherData); } - - if (ECdoc.querySelector("siteData currentConditions humidex")) { - currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions humidex").textContent; + } catch (error) { + Log.error("[envcanada] Fetch city page error:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Failed to fetch city data", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); } } + } - // Need to map EC weather icon to MM weatherType values - currentWeather.weatherType = this.convertWeatherType(ECdoc.querySelector("siteData currentConditions iconCode").textContent); + #parseWeatherData (xml) { + switch (this.config.type) { + case "current": + return this.#generateCurrentWeather(xml); + case "forecast": + case "daily": + return this.#generateForecast(xml); + case "hourly": + return this.#generateHourly(xml); + default: + Log.error(`[envcanada] Unknown weather type: ${this.config.type}`); + return null; + } + } - // Capture the sunrise and sunset values from EC data - const sunList = ECdoc.querySelectorAll("siteData riseSet dateTime"); + #generateCurrentWeather (xml) { + const current = { date: new Date() }; - currentWeather.sunrise = moment(sunList[1].querySelector("timeStamp").textContent, "YYYYMMDDhhmmss"); - currentWeather.sunset = moment(sunList[3].querySelector("timeStamp").textContent, "YYYYMMDDhhmmss"); + // Try to get temperature from currentConditions first + const currentTempStr = this.#extract(xml, /.*?]*>(.*?)<\/temperature>/s); - return currentWeather; - }, + if (currentTempStr && currentTempStr !== "") { + current.temperature = parseFloat(currentTempStr); + this.cacheCurrentTemp = current.temperature; + } else { + // Fallback: extract from first forecast period if currentConditions is empty + const firstForecast = xml.match(/(.*?)<\/forecast>/s); + if (firstForecast) { + const forecastXml = firstForecast[1]; + const temp = this.#extract(forecastXml, /]*>(.*?)<\/temperature>/); + if (temp && temp !== "") { + current.temperature = parseFloat(temp); + this.cacheCurrentTemp = current.temperature; + } else if (this.cacheCurrentTemp !== null) { + current.temperature = this.cacheCurrentTemp; + } else { + current.temperature = null; + } + } + } - /* - * Generate an array of WeatherObjects based on EC weather forecast - */ - generateWeatherObjectsFromForecast (ECdoc) { - // Declare an array to hold each day's forecast object - const days = []; + // Wind chill / humidex for feels like temperature + const windChill = this.#extract(xml, /]*>(.*?)<\/windChill>/); + const humidex = this.#extract(xml, /]*>(.*?)<\/humidex>/); + if (windChill) { + current.feelsLikeTemp = parseFloat(windChill); + } else if (humidex) { + current.feelsLikeTemp = parseFloat(humidex); + } - const weather = new WeatherObject(); - - const foreBaseDates = ECdoc.querySelectorAll("siteData forecastGroup dateTime"); - const baseDate = foreBaseDates[1].querySelector("timeStamp").textContent; - - weather.date = moment(baseDate, "YYYYMMDDhhmmss"); - - const foreGroup = ECdoc.querySelectorAll("siteData forecastGroup forecast"); - - weather.precipitationAmount = null; - - /* - * The EC forecast is held in a 12-element array - Elements 0 to 11 - with each day encompassing - * 2 elements. the first element for a day details the Today (daytime) forecast while the second - * element details the Tonight (nighttime) forecast. Element 0 is always for the current day. - * - * However... the forecast is somewhat 'rolling'. - * - * If the EC forecast is queried in the morning, then Element 0 will contain Current - * Today and Element 1 will contain Current Tonight. From there, the next 5 days of forecast will be - * contained in Elements 2/3, 4/5, 6/7, 8/9, and 10/11. This module will create a 6-day forecast using - * all of these Elements. - * - * But, if the EC forecast is queried in late afternoon, the Current Today forecast will be rolled - * off and Element 0 will contain Current Tonight. From there, the next 5 days will be contained in - * Elements 1/2, 3/4, 5/6, 7/8, and 9/10. As well, Element 11 will contain a forecast for a 6th day, - * but only for the Today portion (not Tonight). This module will create a 6-day forecast using - * Elements 0 to 11, and will ignore the additional Today forecast in Element 11. - * - * We need to determine if Element 0 is showing the forecast for Current Today or Current Tonight. - * This is required to understand how Min and Max temperature will be determined, and to understand - * where the next day's (aka Tomorrow's) forecast is located in the forecast array. - */ - let nextDay = 0; - let lastDay = 0; - const currentTemp = ECdoc.querySelector("siteData currentConditions temperature").textContent; - - // If the first Element is Current Today, look at Current Today and Current Tonight for the current day. - if (foreGroup[0].querySelector("period[textForecastName='Today']")) { - this.todaytempCacheMin = 0; - this.todaytempCacheMax = 0; - this.todayCached = true; - - this.setMinMaxTemps(weather, foreGroup, 0, true, currentTemp); - - this.setPrecipitation(weather, foreGroup, 0); - - /* - * Set the Element number that will reflect where the next day's forecast is located. Also set - * the Element number where the end of the forecast will be. This is important because of the - * rolling nature of the EC forecast. In the current scenario (Today and Tonight are present - * in elements 0 and 11, we know that we will have 6 full days of forecasts and we will use - * them. We will set lastDay such that we iterate through all 12 elements of the forecast. - */ - nextDay = 2; - lastDay = 12; + // Get wind and icon from currentConditions or first forecast + const firstForecast = xml.match(/(.*?)<\/forecast>/s); + if (!firstForecast) { + Log.warn("[envcanada] No forecast data available"); + return current; } - // If the first Element is Current Tonight, look at Tonight only for the current day. - if (foreGroup[0].querySelector("period[textForecastName='Tonight']")) { - this.setMinMaxTemps(weather, foreGroup, 0, false, currentTemp); - - this.setPrecipitation(weather, foreGroup, 0); - - /* - * Set the Element number that will reflect where the next day's forecast is located. Also set - * the Element number where the end of the forecast will be. This is important because of the - * rolling nature of the EC forecast. In the current scenario (only Current Tonight is present - * in Element 0, we know that we will have 6 full days of forecasts PLUS a half-day and - * forecast in the final element. Because we will only use full day forecasts, we set the - * lastDay number to ensure we ignore that final half-day (in forecast Element 11). - */ - nextDay = 1; - lastDay = 11; + const forecastXml = firstForecast[1]; + + // Wind speed - try currentConditions first, fallback to forecast + let windSpeed = this.#extract(xml, /.*?.*?]*>(.*?)<\/speed>/s); + if (!windSpeed) { + windSpeed = this.#extract(forecastXml, /]*>(.*?)<\/speed>/); } + if (windSpeed) { + current.windSpeed = (windSpeed === "calm") ? 0 : convertKmhToMs(parseFloat(windSpeed)); + } + + // Wind bearing - try currentConditions first, fallback to forecast + let windBearing = this.#extract(xml, /.*?.*?]*>(.*?)<\/bearing>/s); + if (!windBearing) { + windBearing = this.#extract(forecastXml, /]*>(.*?)<\/bearing>/); + } + if (windBearing) current.windFromDirection = parseFloat(windBearing); - /* - * Need to map EC weather icon to MM weatherType values. Always pick the first Element's icon to - * reflect either Today or Tonight depending on what the forecast is showing in Element 0. - */ - weather.weatherType = this.convertWeatherType(foreGroup[0].querySelector("abbreviatedForecast iconCode").textContent); + // Try icon from currentConditions first, fallback to forecast + let iconCode = this.#extract(xml, /.*?]*>(.*?)<\/iconCode>/s); + if (!iconCode) { + iconCode = this.#extract(forecastXml, /]*>(.*?)<\/iconCode>/); + } + if (iconCode) current.weatherType = this.#convertWeatherType(iconCode); + + // Humidity from currentConditions + const humidity = this.#extract(xml, /.*?]*>(.*?)<\/relativeHumidity>/s); + if (humidity) current.humidity = parseFloat(humidity); - // Push the weather object into the forecast array. - days.push(weather); + // Precipitation probability from forecast + const pop = this.#extract(forecastXml, /]*>(.*?)<\/pop>/); + if (pop && pop !== "") { + current.precipitationProbability = parseFloat(pop); + } - /* - * Now do the rest of the forecast starting at nextDay. We will process each day using 2 EC - * forecast Elements. This will address the fact that the EC forecast always includes Today and - * Tonight for each day. This is why we iterate through the forecast by a a count of 2, with each - * iteration looking at the current Element and the next Element. - */ - let lastDate = moment(baseDate, "YYYYMMDDhhmmss"); + // Sunrise/sunset (from riseSet, independent of currentConditions) + const sunriseTime = this.#extract(xml, /]*name="sunrise"[^>]*>.*?(.*?)<\/timeStamp>/s); + const sunsetTime = this.#extract(xml, /]*name="sunset"[^>]*>.*?(.*?)<\/timeStamp>/s); + if (sunriseTime) current.sunrise = this.#parseECTime(sunriseTime); + if (sunsetTime) current.sunset = this.#parseECTime(sunsetTime); - for (let stepDay = nextDay; stepDay < lastDay; stepDay += 2) { - let weather = new WeatherObject(); + return current; + } - // Add 1 to the date to reflect the current forecast day we are building - lastDate = lastDate.add(1, "day"); - weather.date = moment(lastDate); + #generateForecast (xml) { + const days = []; + const forecasts = xml.match(/(.*?)<\/forecast>/gs) || []; - /* - * Capture the temperatures for the current Element and the next Element in order to set - * the Min and Max temperatures for the forecast - */ - this.setMinMaxTemps(weather, foreGroup, stepDay, true, currentTemp); + if (forecasts.length === 0) return days; - weather.precipitationAmount = null; + // Get current temp + const currentTempStr = this.#extract(xml, /.*?]*>(.*?)<\/temperature>/s); + const currentTemp = currentTempStr ? parseFloat(currentTempStr) : null; - this.setPrecipitation(weather, foreGroup, stepDay); + // Check if first forecast is Today or Tonight + const isToday = forecasts[0].includes("textForecastName=\"Today\""); - // Need to map EC weather icon to MM weatherType values. Always pick the first Element icon. - weather.weatherType = this.convertWeatherType(foreGroup[stepDay].querySelector("abbreviatedForecast iconCode").textContent); + let nextDay = isToday ? 2 : 1; + const lastDay = isToday ? 12 : 11; - // Push the weather object into the forecast array. - days.push(weather); + // Process first day + const firstDay = { + date: new Date(), + precipitationProbability: null + }; + this.#extractForecastTemps(firstDay, forecasts, 0, isToday, currentTemp); + this.#extractForecastPrecip(firstDay, forecasts, 0); + const firstIcon = this.#extract(forecasts[0], /]*>(.*?)<\/iconCode>/); + if (firstIcon) firstDay.weatherType = this.#convertWeatherType(firstIcon); + days.push(firstDay); + + // Process remaining days + let date = new Date(); + for (let i = nextDay; i < lastDay && i < forecasts.length; i += 2) { + date = new Date(date); + date.setDate(date.getDate() + 1); + + const day = { + date: new Date(date), + precipitationProbability: null + }; + this.#extractForecastTemps(day, forecasts, i, true, currentTemp); + this.#extractForecastPrecip(day, forecasts, i); + const icon = this.#extract(forecasts[i], /]*>(.*?)<\/iconCode>/); + if (icon) day.weatherType = this.#convertWeatherType(icon); + days.push(day); } return days; - }, + } - /* - * Generate an array of WeatherObjects based on EC hourly weather forecast - */ - generateWeatherObjectsFromHourly (ECdoc) { - // Declare an array to hold each hour's forecast object - const hours = []; + #extractForecastTemps (weather, forecasts, index, hasToday, currentTemp) { + let tempToday = null; + let tempTonight = null; - // Get local timezone UTC offset so that each hourly time can be calculated properly - const baseHours = ECdoc.querySelectorAll("siteData hourlyForecastGroup dateTime"); - const hourOffset = baseHours[1].getAttribute("UTCOffset"); + if (hasToday && forecasts[index]) { + const temp = this.#extract(forecasts[index], /]*>(.*?)<\/temperature>/); + if (temp) tempToday = parseFloat(temp); + } - /* - * The EC hourly forecast is held in a 24-element array - Elements 0 to 23 - with Element 0 holding - * the forecast for the next 'on the hour' time slot. This means the array is a rolling 24 hours. - */ - const hourGroup = ECdoc.querySelectorAll("siteData hourlyForecastGroup hourlyForecast"); + if (forecasts[index + 1]) { + const temp = this.#extract(forecasts[index + 1], /]*>(.*?)<\/temperature>/); + if (temp) tempTonight = parseFloat(temp); + } - for (let stepHour = 0; stepHour < 24; stepHour += 1) { - const weather = new WeatherObject(); + if (tempToday !== null && tempTonight !== null) { + weather.maxTemperature = Math.max(tempToday, tempTonight); + weather.minTemperature = Math.min(tempToday, tempTonight); + } else if (tempToday !== null) { + weather.maxTemperature = tempToday; + weather.minTemperature = currentTemp || tempToday; + } else if (tempTonight !== null) { + weather.maxTemperature = currentTemp || tempTonight; + weather.minTemperature = tempTonight; + } + } - // Determine local time by applying UTC offset to the forecast timestamp - const foreTime = moment(hourGroup[stepHour].getAttribute("dateTimeUTC"), "YYYYMMDDhhmmss"); - const currTime = foreTime.add(hourOffset, "hours"); - weather.date = moment(currTime); + #extractForecastPrecip (weather, forecasts, index) { + const precips = []; - // Capture the temperature - weather.temperature = hourGroup[stepHour].querySelector("temperature").textContent; + if (forecasts[index]) { + const pop = this.#extract(forecasts[index], /]*>(.*?)<\/pop>/); + if (pop) precips.push(parseFloat(pop)); + } - // Capture Likelihood of Precipitation (LOP) and unit-of-measure values - const precipLOP = hourGroup[stepHour].querySelector("lop").textContent * 1.0; + if (forecasts[index + 1]) { + const pop = this.#extract(forecasts[index + 1], /]*>(.*?)<\/pop>/); + if (pop) precips.push(parseFloat(pop)); + } - if (precipLOP > 0) { - weather.precipitationProbability = precipLOP; - } + if (precips.length > 0) { + weather.precipitationProbability = Math.max(...precips); + } + } + + #generateHourly (xml) { + const hours = []; + const hourlyMatches = xml.matchAll(/]*dateTimeUTC="([^"]*)"[^>]*>(.*?)<\/hourlyForecast>/gs); + + for (const [, dateTimeUTC, hourXML] of hourlyMatches) { + const weather = {}; - // Need to map EC weather icon to MM weatherType values. Always pick the first Element icon. - weather.weatherType = this.convertWeatherType(hourGroup[stepHour].querySelector("iconCode").textContent); + weather.date = this.#parseECTime(dateTimeUTC); + + const temp = this.#extract(hourXML, /]*>(.*?)<\/temperature>/); + if (temp) weather.temperature = parseFloat(temp); + + const lop = this.#extract(hourXML, /]*>(.*?)<\/lop>/); + if (lop) weather.precipitationProbability = parseFloat(lop); + + const icon = this.#extract(hourXML, /]*>(.*?)<\/iconCode>/); + if (icon) weather.weatherType = this.#convertWeatherType(icon); - // Push the weather object into the forecast array. hours.push(weather); + if (hours.length >= 24) break; } return hours; - }, - - /* - * Determine Min and Max temp based on a supplied Forecast Element index and a boolean that denotes if - * the next Forecast element should be considered - i.e. look at Today *and* Tonight vs.Tonight-only - */ - setMinMaxTemps (weather, foreGroup, today, fullDay, currentTemp) { - const todayTemp = foreGroup[today].querySelector("temperatures temperature").textContent; - - const todayClass = foreGroup[today].querySelector("temperatures temperature").getAttribute("class"); - - /* - * The following logic is largely aimed at accommodating the Current day's forecast whereby we - * can have either Current Today+Current Tonight or only Current Tonight. - * - * If fullDay is false, then we only have Tonight for the current day's forecast - meaning we have - * lost a min or max temp value for the day. Therefore, we will see if we were able to cache the the - * Today forecast for the current day. If we have, we will use them. If we do not have the cached values, - * it means that MM or the Computer has been restarted since the time EC rolled off Today from the - * forecast. In this scenario, we will simply default to the Current Conditions temperature and then - * check the Tonight temperature.x - */ - if (fullDay === false) { - if (this.todayCached === true) { - weather.minTemperature = this.todayTempCacheMin; - weather.maxTemperature = this.todayTempCacheMax; - } else { - weather.minTemperature = currentTemp; - weather.maxTemperature = weather.minTemperature; - } - } + } - /* - * We will check to see if the current Element's temperature is Low or High and set weather values - * accordingly. We will also check the condition where fullDay is true *and* we are looking at forecast - * element 0. This is a special case where we will cache temperature values so that we have them later - * in the current day when the Current Today element rolls off and we have Current Tonight only. - */ - if (todayClass === "low") { - weather.minTemperature = todayTemp; - if (today === 0 && fullDay === true) { - this.todayTempCacheMin = weather.minTemperature; - } - } + #extract (text, pattern) { + const match = text.match(pattern); + return match ? match[1].trim() : null; + } - if (todayClass === "high") { - weather.maxTemperature = todayTemp; - if (today === 0 && fullDay === true) { - this.todayTempCacheMax = weather.maxTemperature; - } + #getIndexUrl () { + const hour = new Date().toISOString().substring(11, 13); + return `https://dd.weather.gc.ca/today/citypage_weather/${this.config.provCode}/${hour}/`; + } + + #extractCityPageURL (html) { + // New format: {timestamp}_MSC_CitypageWeather_{siteCode}_en.xml + const pattern = `[^"]*_MSC_CitypageWeather_${this.config.siteCode}_en\\.xml`; + const match = html.match(new RegExp(`href="(${pattern})"`)); + + if (match && match[1]) { + return this.#getIndexUrl() + match[1]; } - const nextTemp = foreGroup[today + 1].querySelector("temperatures temperature").textContent; + return null; + } - const nextClass = foreGroup[today + 1].querySelector("temperatures temperature").getAttribute("class"); + #parseECTime (timeStr) { + if (!timeStr || timeStr.length < 12) return new Date(); - if (fullDay === true) { - if (nextClass === "low") { - weather.minTemperature = nextTemp; - } + const y = parseInt(timeStr.substring(0, 4), 10); + const m = parseInt(timeStr.substring(4, 6), 10) - 1; + const d = parseInt(timeStr.substring(6, 8), 10); + const h = parseInt(timeStr.substring(8, 10), 10); + const min = parseInt(timeStr.substring(10, 12), 10); + const s = timeStr.length >= 14 ? parseInt(timeStr.substring(12, 14), 10) : 0; - if (nextClass === "high") { - weather.maxTemperature = nextTemp; - } - } - }, - - /* - * Check for a Precipitation forecast. EC can provide a forecast in 2 ways: either an accumulation figure - * or a POP percentage. If there is a POP, then that is what the module will show. If there is an accumulation, - * then it will be displayed ONLY if no POP is present. - * - * POP Logic: By default, we want to show the POP for 'daytime' since we are presuming that is what - * people are more interested in seeing. While EC provides a separate POP for daytime and nighttime portions - * of each day, the weather module does not really allow for that view of a daily forecast. There we will - * ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show - * the nighttime forecast after a certain point in the afternoon. As such, we will be showing the nighttime POP - * (if one exists) in that specific scenario. - * - * Accumulation Logic: Similar to POP, we want to show accumulation for 'daytime' since we presume that is what - * people are interested in seeing. While EC provides a separate accumulation for daytime and nighttime portions - * of each day, the weather module does not really allow for that view of a daily forecast. There we will - * ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show - * the nighttime forecast after a certain point in that specific scenario. - */ - setPrecipitation (weather, foreGroup, today) { - if (foreGroup[today].querySelector("precipitation accumulation")) { - weather.precipitationAmount = foreGroup[today].querySelector("precipitation accumulation amount").textContent * 1.0; - weather.precipitationUnits = foreGroup[today].querySelector("precipitation accumulation amount").getAttribute("units"); - } + // Create UTC date since input timestamps are in UTC + return new Date(Date.UTC(y, m, d, h, min, s)); + } - // Check Today element for POP - const precipPOP = foreGroup[today].querySelector("abbreviatedForecast pop").textContent * 1.0; - if (precipPOP > 0) { - weather.precipitationProbability = precipPOP; - } - }, - - /* - * Convert the icons to a more usable name. - */ - convertWeatherType (weatherType) { - const weatherTypes = { - "00": "day-sunny", - "01": "day-sunny", - "02": "day-sunny-overcast", - "03": "day-cloudy", - "04": "day-cloudy", - "05": "day-cloudy", - "06": "day-sprinkle", - "07": "day-showers", - "08": "day-snow", - "09": "day-thunderstorm", + #convertWeatherType (iconCode) { + const code = parseInt(iconCode, 10); + const map = { + 0: "day-sunny", + 1: "day-sunny", + 2: "day-sunny-overcast", + 3: "day-cloudy", + 4: "day-cloudy", + 5: "day-cloudy", + 6: "day-sprinkle", + 7: "day-showers", + 8: "snow", + 9: "day-thunderstorm", 10: "cloud", 11: "showers", 12: "rain", @@ -614,7 +443,8 @@ WeatherProvider.register("envcanada", { 47: "thunderstorm", 48: "tornado" }; - - return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + return map[code] || null; } -}); +} + +module.exports = EnvCanadaProvider; diff --git a/defaultmodules/weather/providers/openmeteo.js b/defaultmodules/weather/providers/openmeteo.js index c9aaaf567d..c6cff32a24 100644 --- a/defaultmodules/weather/providers/openmeteo.js +++ b/defaultmodules/weather/providers/openmeteo.js @@ -1,231 +1,258 @@ -/* global WeatherProvider, WeatherObject */ - -/* - * This class is a provider for Open-Meteo, - * see https://open-meteo.com/ - */ +const Log = require("logger"); +const { getDateString } = require("../provider-utils"); +const HTTPFetcher = require("#http_fetcher"); // https://www.bigdatacloud.com/docs/api/free-reverse-geocode-to-city-api const GEOCODE_BASE = "https://api.bigdatacloud.net/data/reverse-geocode-client"; const OPEN_METEO_BASE = "https://api.open-meteo.com/v1"; -WeatherProvider.register("openmeteo", { - - /* - * Set the name of the provider. - * Not strictly required but helps for debugging. - */ - providerName: "Open-Meteo", - - // Set the default config properties that is specific to this provider - defaults: { - apiBase: OPEN_METEO_BASE, - lat: 0, - lon: 0, - pastDays: 0, - type: "current" - }, - +/** + * Server-side weather provider for Open-Meteo + * see https://open-meteo.com/ + */ +class OpenMeteoProvider { // https://open-meteo.com/en/docs - hourlyParams: [ - // Air temperature at 2 meters above ground + hourlyParams = [ "temperature_2m", - // Relative humidity at 2 meters above ground "relativehumidity_2m", - // Dew point temperature at 2 meters above ground "dewpoint_2m", - // Apparent temperature is the perceived feels-like temperature combining wind chill factor, relative humidity and solar radiation "apparent_temperature", - // Atmospheric air pressure reduced to mean sea level (msl) or pressure at surface. Typically pressure on mean sea level is used in meteorology. Surface pressure gets lower with increasing elevation. "pressure_msl", "surface_pressure", - // Total cloud cover as an area fraction "cloudcover", - // Low level clouds and fog up to 3 km altitude "cloudcover_low", - // Mid level clouds from 3 to 8 km altitude "cloudcover_mid", - // High level clouds from 8 km altitude "cloudcover_high", - // Wind speed at 10, 80, 120 or 180 meters above ground. Wind speed on 10 meters is the standard level. "windspeed_10m", "windspeed_80m", "windspeed_120m", "windspeed_180m", - // Wind direction at 10, 80, 120 or 180 meters above ground "winddirection_10m", "winddirection_80m", "winddirection_120m", "winddirection_180m", - // Gusts at 10 meters above ground as a maximum of the preceding hour "windgusts_10m", - // Shortwave solar radiation as average of the preceding hour. This is equal to the total global horizontal irradiation "shortwave_radiation", - // Direct solar radiation as average of the preceding hour on the horizontal plane and the normal plane (perpendicular to the sun) "direct_radiation", "direct_normal_irradiance", - // Diffuse solar radiation as average of the preceding hour "diffuse_radiation", - // Vapor Pressure Deificit (VPD) in kilopascal (kPa). For high VPD (>1.6), water transpiration of plants increases. For low VPD (<0.4), transpiration decreases "vapor_pressure_deficit", - // Evapotranspration from land surface and plants that weather models assumes for this location. Available soil water is considered. 1 mm evapotranspiration per hour equals 1 liter of water per spare meter. + "cape", "evapotranspiration", - // ET₀ Reference Evapotranspiration of a well watered grass field. Based on FAO-56 Penman-Monteith equations ET₀ is calculated from temperature, wind speed, humidity and solar radiation. Unlimited soil water is assumed. ET₀ is commonly used to estimate the required irrigation for plants. "et0_fao_evapotranspiration", - // Total precipitation (rain, showers, snow) sum of the preceding hour "precipitation", - // Precipitation Probability - "precipitation_probability", - // UV index - "uv_index", - // Snowfall amount of the preceding hour in centimeters. For the water equivalent in millimeter, divide by 7. E.g. 7 cm snow = 10 mm precipitation water equivalent "snowfall", - // Rain from large scale weather systems of the preceding hour in millimeter + "precipitation_probability", "rain", - // Showers from convective precipitation in millimeters from the preceding hour "showers", - // Weather condition as a numeric code. Follow WMO weather interpretation codes. "weathercode", - // Snow depth on the ground "snow_depth", - // Altitude above sea level of the 0°C level "freezinglevel_height", - // Temperature in the soil at 0, 6, 18 and 54 cm depths. 0 cm is the surface temperature on land or water surface temperature on water. + "visibility", "soil_temperature_0cm", "soil_temperature_6cm", "soil_temperature_18cm", "soil_temperature_54cm", - // Average soil water content as volumetric mixing ratio at 0-1, 1-3, 3-9, 9-27 and 27-81 cm depths. "soil_moisture_0_1cm", "soil_moisture_1_3cm", "soil_moisture_3_9cm", "soil_moisture_9_27cm", - "soil_moisture_27_81cm" - ], - - dailyParams: [ - // Maximum and minimum daily air temperature at 2 meters above ground + "soil_moisture_27_81cm", + "uv_index", + "uv_index_clear_sky", + "is_day", + "terrestrial_radiation", + "terrestrial_radiation_instant", + "shortwave_radiation_instant", + "diffuse_radiation_instant", + "direct_radiation_instant", + "direct_normal_irradiance_instant" + ]; + + dailyParams = [ "temperature_2m_max", "temperature_2m_min", - // Maximum and minimum daily apparent temperature "apparent_temperature_min", "apparent_temperature_max", - // Sum of daily precipitation (including rain, showers and snowfall) "precipitation_sum", - // Sum of daily rain "rain_sum", - // Sum of daily showers "showers_sum", - // Sum of daily snowfall "snowfall_sum", - // The number of hours with rain "precipitation_hours", - // The most severe weather condition on a given day "weathercode", - // Sun rise and set times "sunrise", "sunset", - // Maximum wind speed and gusts on a day "windspeed_10m_max", "windgusts_10m_max", - // Dominant wind direction "winddirection_10m_dominant", - // The sum of solar radiation on a given day in Megajoules "shortwave_radiation_sum", - //UV Index "uv_index_max", - // Daily sum of ET₀ Reference Evapotranspiration of a well watered grass field "et0_fao_evapotranspiration" - ], - - fetchedLocation () { - return this.fetchedLocationName || ""; - }, - - fetchCurrentWeather () { - this.fetchData(this.getUrl()) - .then((data) => this.parseWeatherApiResponse(data)) - .then((parsedData) => { - if (!parsedData) { - // No usable data? - return; - } + ]; - const currentWeather = this.generateWeatherDayFromCurrentWeather(parsedData); - this.setCurrentWeather(currentWeather); - }) - .catch(function (request) { - Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - fetchWeatherForecast () { - this.fetchData(this.getUrl()) - .then((data) => this.parseWeatherApiResponse(data)) - .then((parsedData) => { - if (!parsedData) { - // No usable data? - return; - } + constructor (config) { + this.config = { + apiBase: OPEN_METEO_BASE, + lat: 0, + lon: 0, + pastDays: 0, + type: "current", + maxNumberOfDays: 5, + maxEntries: 5, + updateInterval: 10 * 60 * 1000, + ...config + }; - const dailyForecast = this.generateWeatherObjectsFromForecast(parsedData); - this.setWeatherForecast(dailyForecast); - }) - .catch(function (request) { - Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - fetchWeatherHourly () { - this.fetchData(this.getUrl()) - .then((data) => this.parseWeatherApiResponse(data)) - .then((parsedData) => { - if (!parsedData) { - // No usable data? - return; - } + this.locationName = null; + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + } - const hourlyForecast = this.generateWeatherObjectsFromHourly(parsedData); - this.setWeatherHourly(hourlyForecast); - }) - .catch(function (request) { - Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, + async initialize () { + await this.#fetchLocation(); + this.#initializeFetcher(); + } /** - * Overrides method for setting config to check if endpoint is correct for hourly - * @param {object} config The configuration object + * Set callbacks for data/error events + * @param {(data: object) => void} onData - Called with weather data + * @param {(error: object) => void} onError - Called with error info */ - setConfig (config) { - this.config = { - lang: config.lang ?? "en", - ...this.defaults, - ...config - }; + setCallbacks (onData, onError) { + this.onDataCallback = onData; + this.onErrorCallback = onError; + } - // Set properly maxNumberOfDays and max Entries properties according to config and value ranges allowed in the documentation - const maxEntriesLimit = ["daily", "forecast"].includes(this.config.type) ? 7 : this.config.type === "hourly" ? 48 : 0; - if (this.config.hasOwnProperty("maxNumberOfDays") && !isNaN(parseFloat(this.config.maxNumberOfDays))) { - const daysFactor = ["daily", "forecast"].includes(this.config.type) ? 1 : this.config.type === "hourly" ? 24 : 0; - this.config.maxEntries = Math.max(1, Math.min(Math.round(parseFloat(this.config.maxNumberOfDays)) * daysFactor, maxEntriesLimit)); - this.config.maxNumberOfDays = Math.ceil(this.config.maxEntries / Math.max(1, daysFactor)); + /** + * Start periodic fetching + */ + start () { + if (this.fetcher) { + this.fetcher.startPeriodicFetch(); } - this.config.maxEntries = Math.max(1, Math.min(this.config.maxEntries, maxEntriesLimit)); + } - if (!this.config.type) { - Log.error("[weatherprovider.openmeteo] type not configured and could not resolve it"); + /** + * Stop periodic fetching + */ + stop () { + if (this.fetcher) { + this.fetcher.clearTimer(); + } + } + + async #fetchLocation () { + const url = `${GEOCODE_BASE}?latitude=${this.config.lat}&longitude=${this.config.lon}&localityLanguage=${this.config.lang || "en"}`; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + if (data && data.city) { + this.locationName = `${data.city}, ${data.principalSubdivisionCode}`; + } + } catch (error) { + Log.debug("[openmeteo] Could not load location data:", error.message); + } + } + + #initializeFetcher () { + const url = this.#getUrl(); + + this.fetcher = new HTTPFetcher(url, { + reloadInterval: this.config.updateInterval, + headers: { "Cache-Control": "no-cache" }, + logContext: "weatherprovider.openmeteo" + }); + + this.fetcher.on("response", async (response) => { + try { + const data = await response.json(); + this.#handleResponse(data); + } catch (error) { + Log.error("[openmeteo] Failed to parse JSON:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Failed to parse API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + } + }); + + this.fetcher.on("error", (errorInfo) => { + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); + } + }); + } + + #handleResponse (data) { + const parsedData = this.#parseWeatherApiResponse(data); + + if (!parsedData) { + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Invalid API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + return; + } + + try { + let weatherData; + switch (this.config.type) { + case "current": + weatherData = this.#generateWeatherDayFromCurrentWeather(parsedData); + break; + case "forecast": + case "daily": + weatherData = this.#generateWeatherObjectsFromForecast(parsedData); + break; + case "hourly": + weatherData = this.#generateWeatherObjectsFromHourly(parsedData); + break; + default: + Log.error(`[openmeteo] Unknown type: ${this.config.type}`); + throw new Error(`Unknown weather type: ${this.config.type}`); + } + + if (weatherData && this.onDataCallback) { + this.onDataCallback(weatherData); + } + } catch (error) { + Log.error("[openmeteo] Error processing weather data:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: error.message, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } } + } - this.fetchLocation(); - }, + #getQueryParameters () { + const maxEntriesLimit = ["daily", "forecast"].includes(this.config.type) ? 7 : this.config.type === "hourly" ? 48 : 0; + let maxEntries = this.config.maxEntries; + let maxNumberOfDays = this.config.maxNumberOfDays; - // Generate valid query params to perform the request - getQueryParameters () { - let params = { + if (this.config.maxNumberOfDays !== undefined && !isNaN(parseFloat(this.config.maxNumberOfDays))) { + const daysFactor = ["daily", "forecast"].includes(this.config.type) ? 1 : this.config.type === "hourly" ? 24 : 0; + maxEntries = Math.max(1, Math.min(Math.round(parseFloat(this.config.maxNumberOfDays)) * daysFactor, maxEntriesLimit)); + maxNumberOfDays = Math.ceil(maxEntries / Math.max(1, daysFactor)); + } + maxEntries = Math.max(1, Math.min(maxEntries, maxEntriesLimit)); + + const params = { latitude: this.config.lat, longitude: this.config.lon, timeformat: "unixtime", @@ -233,36 +260,34 @@ WeatherProvider.register("openmeteo", { past_days: this.config.pastDays ?? 0, daily: this.dailyParams, hourly: this.hourlyParams, - // Fixed units as metric temperature_unit: "celsius", windspeed_unit: "ms", precipitation_unit: "mm" }; - const startDate = moment().startOf("day"); - const endDate = moment(startDate) - .add(Math.max(0, Math.min(7, this.config.maxNumberOfDays)), "days") - .endOf("day"); + const now = new Date(); + const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + Math.max(0, Math.min(7, maxNumberOfDays))); - params.start_date = startDate.format("YYYY-MM-DD"); + params.start_date = startDate.toISOString().split("T")[0]; switch (this.config.type) { case "hourly": case "daily": case "forecast": - params.end_date = endDate.format("YYYY-MM-DD"); + params.end_date = endDate.toISOString().split("T")[0]; break; case "current": params.current_weather = true; params.end_date = params.start_date; break; default: - // Failsafe return ""; } return Object.keys(params) - .filter((key) => (!!params[key])) + .filter((key) => params[key] !== undefined && params[key] !== null && params[key] !== "") .map((key) => { switch (key) { case "hourly": @@ -273,200 +298,53 @@ WeatherProvider.register("openmeteo", { } }) .join("&"); - }, - - // Create a URL from the config and base URL. - getUrl () { - return `${this.config.apiBase}/forecast?${this.getQueryParameters()}`; - }, - - // fix daylight-saving-time differences - checkDST (dt) { - const uxdt = moment.unix(dt); - const nowDST = moment().isDST(); - if (nowDST === moment(uxdt).isDST()) { - return uxdt; - } else { - return uxdt.add(nowDST ? +1 : -1, "hour"); - } - }, + } + + #getUrl () { + return `${this.config.apiBase}/forecast?${this.#getQueryParameters()}`; + } - // Transpose hourly and daily data matrices - transposeDataMatrix (data) { + #transposeDataMatrix (data) { return data.time.map((_, index) => Object.keys(data).reduce((row, key) => { + const value = data[key][index]; return { ...row, - // Parse time values as moment.js instances - [key]: ["time", "sunrise", "sunset"].includes(key) ? this.checkDST(data[key][index]) : data[key][index] + // Convert Unix timestamps to Date objects + // timezone: "auto" returns times already in location timezone + [key]: ["time", "sunrise", "sunset"].includes(key) ? new Date(value * 1000) : value }; }, {})); - }, + } - // Sanitize and validate API response - parseWeatherApiResponse (data) { + #parseWeatherApiResponse (data) { const validByType = { current: data.current_weather && data.current_weather.time, hourly: data.hourly && data.hourly.time && Array.isArray(data.hourly.time) && data.hourly.time.length > 0, daily: data.daily && data.daily.time && Array.isArray(data.daily.time) && data.daily.time.length > 0 }; - // backwards compatibility + const type = ["daily", "forecast"].includes(this.config.type) ? "daily" : this.config.type; - if (!validByType[type]) return; + if (!validByType[type]) return null; - switch (type) { - case "current": - if (!validByType.daily && !validByType.hourly) { - return; - } - break; - case "hourly": - case "daily": - break; - default: - return; + if (type === "current" && !validByType.daily && !validByType.hourly) { + return null; } for (const key of ["hourly", "daily"]) { if (typeof data[key] === "object") { - data[key] = this.transposeDataMatrix(data[key]); + data[key] = this.#transposeDataMatrix(data[key]); } } if (data.current_weather) { - data.current_weather.time = moment.unix(data.current_weather.time); + data.current_weather.time = new Date(data.current_weather.time * 1000); } return data; - }, - - // Reverse geocoding from latitude and longitude provided - fetchLocation () { - this.fetchData(`${GEOCODE_BASE}?latitude=${this.config.lat}&longitude=${this.config.lon}&localityLanguage=${this.config.lang}`) - .then((data) => { - if (!data || !data.city) { - // No usable data? - return; - } - this.fetchedLocationName = `${data.city}, ${data.principalSubdivisionCode}`; - }) - .catch((request) => { - Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); - }); - }, - - // Implement WeatherDay generator. - generateWeatherDayFromCurrentWeather (weather) { - - /** - * Since some units come from API response "splitted" into daily, hourly and current_weather - * every time you request it, you have to ensure to get the data from the right place every time. - * For the current weather case, the response have the following structure (after transposing): - * ``` - * { - * current_weather: { ... }, - * hourly: [ - * 0: {... }, - * 1: {... }, - * ... - * ], - * daily: [ - * {... }, - * ] - * } - * ``` - * Some data should be returned from `hourly` array data when the index matches the current hour, - * some data from the first and only one object received in `daily` array and some from the - * `current_weather` object. - */ - const h = moment().hour(); - const currentWeather = new WeatherObject(); - - currentWeather.date = weather.current_weather.time; - currentWeather.windSpeed = weather.current_weather.windspeed; - currentWeather.windFromDirection = weather.current_weather.winddirection; - currentWeather.sunrise = weather.daily[0].sunrise; - currentWeather.sunset = weather.daily[0].sunset; - currentWeather.temperature = parseFloat(weather.current_weather.temperature); - currentWeather.minTemperature = parseFloat(weather.daily[0].temperature_2m_min); - currentWeather.maxTemperature = parseFloat(weather.daily[0].temperature_2m_max); - currentWeather.weatherType = this.convertWeatherType(weather.current_weather.weathercode, currentWeather.isDayTime()); - currentWeather.humidity = parseFloat(weather.hourly[h].relativehumidity_2m); - currentWeather.feelsLikeTemp = parseFloat(weather.hourly[h].apparent_temperature); - currentWeather.rain = parseFloat(weather.hourly[h].rain); - currentWeather.snow = parseFloat(weather.hourly[h].snowfall * 10); - currentWeather.precipitationAmount = parseFloat(weather.hourly[h].precipitation); - currentWeather.precipitationProbability = parseFloat(weather.hourly[h].precipitation_probability); - currentWeather.uv_index = parseFloat(weather.hourly[h].uv_index); - - return currentWeather; - }, - - // Implement WeatherForecast generator. - generateWeatherObjectsFromForecast (weathers) { - const days = []; - - weathers.daily.forEach((weather) => { - const currentWeather = new WeatherObject(); - - currentWeather.date = weather.time; - currentWeather.windSpeed = weather.windspeed_10m_max; - currentWeather.windFromDirection = weather.winddirection_10m_dominant; - currentWeather.sunrise = weather.sunrise; - currentWeather.sunset = weather.sunset; - currentWeather.temperature = parseFloat((weather.temperature_2m_max + weather.temperature_2m_min) / 2); - currentWeather.minTemperature = parseFloat(weather.temperature_2m_min); - currentWeather.maxTemperature = parseFloat(weather.temperature_2m_max); - currentWeather.weatherType = this.convertWeatherType(weather.weathercode, true); - currentWeather.rain = parseFloat(weather.rain_sum); - currentWeather.snow = parseFloat(weather.snowfall_sum * 10); - currentWeather.precipitationAmount = parseFloat(weather.precipitation_sum); - currentWeather.precipitationProbability = parseFloat(weather.precipitation_hours * 100 / 24); - currentWeather.uv_index = parseFloat(weather.uv_index_max); - - days.push(currentWeather); - }); - - return days; - }, - - // Implement WeatherHourly generator. - generateWeatherObjectsFromHourly (weathers) { - const hours = []; - const now = moment(); - - weathers.hourly.forEach((weather, i) => { - if ((hours.length === 0 && weather.time <= now) || hours.length >= this.config.maxEntries) { - return; - } - - const currentWeather = new WeatherObject(); - const h = Math.ceil((i + 1) / 24) - 1; - - currentWeather.date = weather.time; - currentWeather.windSpeed = weather.windspeed_10m; - currentWeather.windFromDirection = weather.winddirection_10m; - currentWeather.sunrise = weathers.daily[h].sunrise; - currentWeather.sunset = weathers.daily[h].sunset; - currentWeather.temperature = parseFloat(weather.temperature_2m); - currentWeather.minTemperature = parseFloat(weathers.daily[h].temperature_2m_min); - currentWeather.maxTemperature = parseFloat(weathers.daily[h].temperature_2m_max); - currentWeather.weatherType = this.convertWeatherType(weather.weathercode, currentWeather.isDayTime()); - currentWeather.humidity = parseFloat(weather.relativehumidity_2m); - currentWeather.rain = parseFloat(weather.rain); - currentWeather.snow = parseFloat(weather.snowfall * 10); - currentWeather.precipitationAmount = parseFloat(weather.precipitation); - currentWeather.precipitationProbability = parseFloat(weather.precipitation_probability); - currentWeather.uv_index = parseFloat(weather.uv_index); - - hours.push(currentWeather); - }); - - return hours; - }, + } - // Map icons from Dark Sky to our icons. - convertWeatherType (weathercode, isDayTime) { + #convertWeatherType (weathercode, isDayTime) { const weatherConditions = { 0: "clear", 1: "mainly-clear", @@ -498,60 +376,188 @@ WeatherProvider.register("openmeteo", { 99: "thunderstorm-heavy-hail" }; - if (!Object.keys(weatherConditions).includes(`${weathercode}`)) return null; - - switch (weatherConditions[`${weathercode}`]) { - case "clear": - return isDayTime ? "day-sunny" : "night-clear"; - case "mainly-clear": - case "partly-cloudy": - return isDayTime ? "day-cloudy" : "night-alt-cloudy"; - case "overcast": - return isDayTime ? "day-sunny-overcast" : "night-alt-partly-cloudy"; - case "fog": - case "depositing-rime-fog": - return isDayTime ? "day-fog" : "night-fog"; - case "drizzle-light-intensity": - case "rain-slight-intensity": - case "rain-showers-slight": - return isDayTime ? "day-sprinkle" : "night-sprinkle"; - case "drizzle-moderate-intensity": - case "rain-moderate-intensity": - case "rain-showers-moderate": - return isDayTime ? "day-showers" : "night-showers"; - case "drizzle-dense-intensity": - case "rain-heavy-intensity": - case "rain-showers-violent": - return isDayTime ? "day-thunderstorm" : "night-thunderstorm"; - case "freezing-rain-light-intensity": - return isDayTime ? "day-rain-mix" : "night-rain-mix"; - case "freezing-drizzle-light-intensity": - case "freezing-drizzle-dense-intensity": - return "snowflake-cold"; - case "snow-grains": - return isDayTime ? "day-sleet" : "night-sleet"; - case "snow-fall-slight-intensity": - case "snow-fall-moderate-intensity": - return isDayTime ? "day-snow-wind" : "night-snow-wind"; - case "snow-fall-heavy-intensity": - case "freezing-rain-heavy-intensity": - return isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm"; - case "snow-showers-slight": - case "snow-showers-heavy": - return isDayTime ? "day-rain-mix" : "night-rain-mix"; - case "thunderstorm": - return isDayTime ? "day-thunderstorm" : "night-thunderstorm"; - case "thunderstorm-slight-hail": - return isDayTime ? "day-sleet" : "night-sleet"; - case "thunderstorm-heavy-hail": - return isDayTime ? "day-sleet-storm" : "night-sleet-storm"; - default: - return "na"; + if (!(weathercode in weatherConditions)) return null; + + const mappings = { + clear: isDayTime ? "day-sunny" : "night-clear", + "mainly-clear": isDayTime ? "day-cloudy" : "night-alt-cloudy", + "partly-cloudy": isDayTime ? "day-cloudy" : "night-alt-cloudy", + overcast: isDayTime ? "day-sunny-overcast" : "night-alt-partly-cloudy", + fog: isDayTime ? "day-fog" : "night-fog", + "depositing-rime-fog": isDayTime ? "day-fog" : "night-fog", + "drizzle-light-intensity": isDayTime ? "day-sprinkle" : "night-sprinkle", + "rain-slight-intensity": isDayTime ? "day-sprinkle" : "night-sprinkle", + "rain-showers-slight": isDayTime ? "day-sprinkle" : "night-sprinkle", + "drizzle-moderate-intensity": isDayTime ? "day-showers" : "night-showers", + "rain-moderate-intensity": isDayTime ? "day-showers" : "night-showers", + "rain-showers-moderate": isDayTime ? "day-showers" : "night-showers", + "drizzle-dense-intensity": isDayTime ? "day-thunderstorm" : "night-thunderstorm", + "rain-heavy-intensity": isDayTime ? "day-thunderstorm" : "night-thunderstorm", + "rain-showers-violent": isDayTime ? "day-thunderstorm" : "night-thunderstorm", + "freezing-rain-light-intensity": isDayTime ? "day-rain-mix" : "night-rain-mix", + "freezing-drizzle-light-intensity": "snowflake-cold", + "freezing-drizzle-dense-intensity": "snowflake-cold", + "snow-grains": isDayTime ? "day-sleet" : "night-sleet", + "snow-fall-slight-intensity": isDayTime ? "day-snow-wind" : "night-snow-wind", + "snow-fall-moderate-intensity": isDayTime ? "day-snow-wind" : "night-snow-wind", + "snow-fall-heavy-intensity": isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm", + "freezing-rain-heavy-intensity": isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm", + "snow-showers-slight": isDayTime ? "day-rain-mix" : "night-rain-mix", + "snow-showers-heavy": isDayTime ? "day-rain-mix" : "night-rain-mix", + thunderstorm: isDayTime ? "day-thunderstorm" : "night-thunderstorm", + "thunderstorm-slight-hail": isDayTime ? "day-sleet" : "night-sleet", + "thunderstorm-heavy-hail": isDayTime ? "day-sleet-storm" : "night-sleet-storm" + }; + + return mappings[weatherConditions[`${weathercode}`]] || "na"; + } + + #isDayTime (date, sunrise, sunset) { + const time = date.getTime(); + return time >= sunrise.getTime() && time < sunset.getTime(); + } + + #generateWeatherDayFromCurrentWeather (parsedData) { + // Basic current weather data + const current = { + date: parsedData.current_weather.time, + windSpeed: parsedData.current_weather.windspeed, + windFromDirection: parsedData.current_weather.winddirection, + temperature: parsedData.current_weather.temperature, + weatherType: this.#convertWeatherType(parsedData.current_weather.weathercode, true) + }; + + // Add hourly data if available + if (parsedData.hourly) { + let h = 0; + const currentTime = parsedData.current_weather.time; + + // Handle both data shapes: object with arrays or array of objects (after transpose) + if (Array.isArray(parsedData.hourly)) { + // Array of objects (after transpose) + const hourlyIndex = parsedData.hourly.findIndex((hour) => hour.time.getTime() === currentTime.getTime()); + h = hourlyIndex !== -1 ? hourlyIndex : 0; + + if (hourlyIndex === -1) { + Log.debug("[openmeteo] Could not find current time in hourly data, using index 0"); + } + + const hourData = parsedData.hourly[h]; + if (hourData) { + current.humidity = hourData.relativehumidity_2m; + current.feelsLikeTemp = hourData.apparent_temperature; + current.rain = hourData.rain; + current.snow = hourData.snowfall ? hourData.snowfall * 10 : undefined; + current.precipitationAmount = hourData.precipitation; + current.precipitationProbability = hourData.precipitation_probability; + current.uvIndex = hourData.uv_index; + } + } else if (parsedData.hourly.time) { + // Object with arrays (before transpose - shouldn't happen in normal flow) + const hourlyIndex = parsedData.hourly.time.findIndex((time) => time === currentTime); + h = hourlyIndex !== -1 ? hourlyIndex : 0; + + if (hourlyIndex === -1) { + Log.debug("[openmeteo] Could not find current time in hourly data, using index 0"); + } + + current.humidity = parsedData.hourly.relativehumidity_2m?.[h]; + current.feelsLikeTemp = parsedData.hourly.apparent_temperature?.[h]; + current.rain = parsedData.hourly.rain?.[h]; + current.snow = parsedData.hourly.snowfall?.[h] ? parsedData.hourly.snowfall[h] * 10 : undefined; + current.precipitationAmount = parsedData.hourly.precipitation?.[h]; + current.precipitationProbability = parsedData.hourly.precipitation_probability?.[h]; + current.uvIndex = parsedData.hourly.uv_index?.[h]; + } + } + + // Add daily data if available (after transpose, daily is array of objects) + if (parsedData.daily && Array.isArray(parsedData.daily) && parsedData.daily[0]) { + const today = parsedData.daily[0]; + if (today.sunrise) { + current.sunrise = today.sunrise; + } + if (today.sunset) { + current.sunset = today.sunset; + // Update weatherType with correct day/night status + if (current.sunrise && current.sunset) { + current.weatherType = this.#convertWeatherType( + parsedData.current_weather.weathercode, + this.#isDayTime(parsedData.current_weather.time, current.sunrise, current.sunset) + ); + } + } + if (today.temperature_2m_min !== undefined) { + current.minTemperature = today.temperature_2m_min; + } + if (today.temperature_2m_max !== undefined) { + current.maxTemperature = today.temperature_2m_max; + } } - }, - // Define required scripts. - getScripts () { - return ["moment.js"]; + return current; + } + + #generateWeatherObjectsFromForecast (parsedData) { + return parsedData.daily.map((weather) => ({ + date: weather.time, + windSpeed: weather.windspeed_10m_max, + windFromDirection: weather.winddirection_10m_dominant, + sunrise: weather.sunrise, + sunset: weather.sunset, + temperature: parseFloat((weather.temperature_2m_max + weather.temperature_2m_min) / 2), + minTemperature: parseFloat(weather.temperature_2m_min), + maxTemperature: parseFloat(weather.temperature_2m_max), + weatherType: this.#convertWeatherType(weather.weathercode, true), + rain: weather.rain_sum != null ? parseFloat(weather.rain_sum) : null, + snow: weather.snowfall_sum != null ? parseFloat(weather.snowfall_sum * 10) : null, + precipitationAmount: weather.precipitation_sum != null ? parseFloat(weather.precipitation_sum) : null, + precipitationProbability: weather.precipitation_hours != null ? parseFloat(weather.precipitation_hours * 100 / 24) : null, + uvIndex: weather.uv_index_max != null ? parseFloat(weather.uv_index_max) : null + })); + } + + #generateWeatherObjectsFromHourly (parsedData) { + const hours = []; + const now = new Date(); + + parsedData.hourly.forEach((weather, i) => { + // Skip past entries, collect only future hours up to maxEntries + if (weather.time <= now || hours.length >= this.config.maxEntries) { + return; + } + + // Calculate daily index with bounds check + const h = Math.ceil((i + 1) / 24) - 1; + const safeH = Math.max(0, Math.min(h, parsedData.daily.length - 1)); + const dailyData = parsedData.daily[safeH]; + + const hourlyWeather = { + date: weather.time, + windSpeed: weather.windspeed_10m, + windFromDirection: weather.winddirection_10m, + sunrise: dailyData.sunrise, + sunset: dailyData.sunset, + temperature: parseFloat(weather.temperature_2m), + minTemperature: parseFloat(dailyData.temperature_2m_min), + maxTemperature: parseFloat(dailyData.temperature_2m_max), + weatherType: this.#convertWeatherType( + weather.weathercode, + this.#isDayTime(weather.time, dailyData.sunrise, dailyData.sunset) + ), + humidity: weather.relativehumidity_2m != null ? parseFloat(weather.relativehumidity_2m) : null, + rain: weather.rain != null ? parseFloat(weather.rain) : null, + snow: weather.snowfall != null ? parseFloat(weather.snowfall * 10) : null, + precipitationAmount: weather.precipitation != null ? parseFloat(weather.precipitation) : null, + precipitationProbability: weather.precipitation_probability != null ? parseFloat(weather.precipitation_probability) : null, + uvIndex: weather.uv_index != null ? parseFloat(weather.uv_index) : null + }; + + hours.push(hourlyWeather); + }); + + return hours; } -}); +} + +module.exports = OpenMeteoProvider; diff --git a/defaultmodules/weather/providers/openweathermap.js b/defaultmodules/weather/providers/openweathermap.js index 5ad0fa1460..c75e1f927c 100644 --- a/defaultmodules/weather/providers/openweathermap.js +++ b/defaultmodules/weather/providers/openweathermap.js @@ -1,290 +1,157 @@ -/* global WeatherProvider, WeatherObject */ +const Log = require("logger"); +const weatherUtils = require("../provider-utils"); +const HTTPFetcher = require("#http_fetcher"); -/* - * This class is a provider for Openweathermap, +/** + * Server-side weather provider for OpenWeatherMap * see https://openweathermap.org/ */ -WeatherProvider.register("openweathermap", { - - /* - * Set the name of the provider. - * This isn't strictly necessary, since it will fallback to the provider identifier - * But for debugging (and future alerts) it would be nice to have the real name. - */ - providerName: "OpenWeatherMap", - - // Set the default config properties that is specific to this provider - defaults: { - apiVersion: "3.0", - apiBase: "https://api.openweathermap.org/data/", - // weatherEndpoint is "/onecall" since API 3.0 - // "/onecall", "/forecast" or "/weather" only for pro customers - weatherEndpoint: "/onecall", - locationID: false, - location: false, - // the /onecall endpoint needs lat / lon values, it doesn't support the locationId - lat: 0, - lon: 0, - apiKey: "" - }, - - // Overwrite the fetchCurrentWeather method. - fetchCurrentWeather () { - this.fetchData(this.getUrl()) - .then((data) => { - let currentWeather; - if (this.config.weatherEndpoint === "/onecall") { - currentWeather = this.generateWeatherObjectsFromOnecall(data).current; - this.setFetchedLocation(`${data.timezone}`); - } else { - currentWeather = this.generateWeatherObjectFromCurrentWeather(data); - } - this.setCurrentWeather(currentWeather); - }) - .catch(function (request) { - Log.error("[weatherprovider.openweathermap] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - // Overwrite the fetchWeatherForecast method. - fetchWeatherForecast () { - this.fetchData(this.getUrl()) - .then((data) => { - let forecast; - let location; - if (this.config.weatherEndpoint === "/onecall") { - forecast = this.generateWeatherObjectsFromOnecall(data).days; - location = `${data.timezone}`; - } else { - forecast = this.generateWeatherObjectsFromForecast(data.list); - location = `${data.city.name}, ${data.city.country}`; - } - this.setWeatherForecast(forecast); - this.setFetchedLocation(location); - }) - .catch(function (request) { - Log.error("[weatherprovider.openweathermap] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - // Overwrite the fetchWeatherHourly method. - fetchWeatherHourly () { - this.fetchData(this.getUrl()) - .then((data) => { - if (!data) { - - /* - * Did not receive usable new data. - * Maybe this needs a better check? - */ - return; - } +class OpenWeatherMapProvider { + constructor (config) { + this.config = { + apiVersion: "3.0", + apiBase: "https://api.openweathermap.org/data/", + weatherEndpoint: "/onecall", + locationID: false, + location: false, + lat: 0, + lon: 0, + apiKey: "", + type: "current", + updateInterval: 10 * 60 * 1000, + ...config + }; + + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + this.locationName = null; + } - this.setFetchedLocation(`(${data.lat},${data.lon})`); - - const weatherData = this.generateWeatherObjectsFromOnecall(data); - this.setWeatherHourly(weatherData.hours); - }) - .catch(function (request) { - Log.error("[weatherprovider.openweathermap] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - /** OpenWeatherMap Specific Methods - These are not part of the default provider methods */ - /* - * Gets the complete url for the request - */ - getUrl () { - return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.getParams(); - }, - - /* - * Generate a WeatherObject based on currentWeatherInformation - */ - generateWeatherObjectFromCurrentWeather (currentWeatherData) { - const currentWeather = new WeatherObject(); - - currentWeather.date = moment.unix(currentWeatherData.dt); - currentWeather.humidity = currentWeatherData.main.humidity; - currentWeather.temperature = currentWeatherData.main.temp; - currentWeather.feelsLikeTemp = currentWeatherData.main.feels_like; - currentWeather.windSpeed = currentWeatherData.wind.speed; - currentWeather.windFromDirection = currentWeatherData.wind.deg; - currentWeather.weatherType = this.convertWeatherType(currentWeatherData.weather[0].icon); - currentWeather.sunrise = moment.unix(currentWeatherData.sys.sunrise); - currentWeather.sunset = moment.unix(currentWeatherData.sys.sunset); - - return currentWeather; - }, - - /* - * Generate WeatherObjects based on forecast information - */ - generateWeatherObjectsFromForecast (forecasts) { - if (this.config.weatherEndpoint === "/forecast") { - return this.generateForecastHourly(forecasts); - } else if (this.config.weatherEndpoint === "/forecast/daily") { - return this.generateForecastDaily(forecasts); + async initialize () { + // Validate callbacks exist + if (typeof this.onErrorCallback !== "function") { + throw new Error("setCallbacks() must be called before initialize()"); } - // if weatherEndpoint does not match forecast or forecast/daily, what should be returned? - return [new WeatherObject()]; - }, - - /* - * Generate WeatherObjects based on One Call forecast information - */ - generateWeatherObjectsFromOnecall (data) { - if (this.config.weatherEndpoint === "/onecall") { - return this.fetchOnecall(data); + + if (!this.config.apiKey) { + Log.error("[openweathermap] API key is required"); + this.onErrorCallback({ + message: "API key is required", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + return; } - // if weatherEndpoint does not match onecall, what should be returned? - return { current: new WeatherObject(), hours: [], days: [] }; - }, - - /* - * Generate forecast information for 3-hourly forecast (available for free - * subscription). - */ - generateForecastHourly (forecasts) { - // initial variable declaration - const days = []; - // variables for temperature range and rain - let minTemp = []; - let maxTemp = []; - let rain = 0; - let snow = 0; - // variable for date - let date = ""; - let weather = new WeatherObject(); - - for (const forecast of forecasts) { - if (date !== moment.unix(forecast.dt).format("YYYY-MM-DD")) { - // calculate minimum/maximum temperature, specify rain amount - weather.minTemperature = Math.min.apply(null, minTemp); - weather.maxTemperature = Math.max.apply(null, maxTemp); - weather.rain = rain; - weather.snow = snow; - weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); - // push weather information to days array - days.push(weather); - // create new weather-object - weather = new WeatherObject(); - minTemp = []; - maxTemp = []; - rain = 0; - snow = 0; + this.#initializeFetcher(); + } - // set new date - date = moment.unix(forecast.dt).format("YYYY-MM-DD"); + setCallbacks (onData, onError) { + this.onDataCallback = onData; + this.onErrorCallback = onError; + } - // specify date - weather.date = moment.unix(forecast.dt); + start () { + if (this.fetcher) { + this.fetcher.startPeriodicFetch(); + } + } - // If the first value of today is later than 17:00, we have an icon at least! - weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); - } + stop () { + if (this.fetcher) { + this.fetcher.clearTimer(); + } + } - if (moment.unix(forecast.dt).format("H") >= 8 && moment.unix(forecast.dt).format("H") <= 17) { - weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); + #initializeFetcher () { + const url = this.#getUrl(); + + this.fetcher = new HTTPFetcher(url, { + reloadInterval: this.config.updateInterval, + headers: { "Cache-Control": "no-cache" }, + logContext: "weatherprovider.openweathermap" + }); + + this.fetcher.on("response", async (response) => { + try { + const data = await response.json(); + this.#handleResponse(data); + } catch (error) { + Log.error("[openweathermap] Failed to parse JSON:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Failed to parse API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } } + }); - /* - * the same day as before - * add values from forecast to corresponding variables - */ - minTemp.push(forecast.main.temp_min); - maxTemp.push(forecast.main.temp_max); - - if (forecast.hasOwnProperty("rain") && !isNaN(forecast.rain["3h"])) { - rain += forecast.rain["3h"]; + this.fetcher.on("error", (errorInfo) => { + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); } + }); + } - if (forecast.hasOwnProperty("snow") && !isNaN(forecast.snow["3h"])) { - snow += forecast.snow["3h"]; + #handleResponse (data) { + try { + // Set location name from timezone + if (data.timezone) { + this.locationName = data.timezone; } - } - /* - * last day - * calculate minimum/maximum temperature, specify rain amount - */ - weather.minTemperature = Math.min.apply(null, minTemp); - weather.maxTemperature = Math.max.apply(null, maxTemp); - weather.rain = rain; - weather.snow = snow; - weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); - // push weather information to days array - days.push(weather); - return days.slice(1); - }, - - /* - * Generate forecast information for daily forecast (available for paid - * subscription or old apiKey). - */ - generateForecastDaily (forecasts) { - // initial variable declaration - const days = []; - - for (const forecast of forecasts) { - const weather = new WeatherObject(); - - weather.date = moment.unix(forecast.dt); - weather.minTemperature = forecast.temp.min; - weather.maxTemperature = forecast.temp.max; - weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); - weather.rain = 0; - weather.snow = 0; - - /* - * forecast.rain not available if amount is zero - * The API always returns in millimeters - */ - if (forecast.hasOwnProperty("rain") && !isNaN(forecast.rain)) { - weather.rain = forecast.rain; + let weatherData; + const onecallData = this.#generateWeatherObjectsFromOnecall(data); + + switch (this.config.type) { + case "current": + weatherData = onecallData.current; + break; + case "forecast": + case "daily": + weatherData = onecallData.days; + break; + case "hourly": + weatherData = onecallData.hours; + break; + default: + Log.error(`[openweathermap] Unknown type: ${this.config.type}`); + throw new Error(`Unknown weather type: ${this.config.type}`); } - /* - * forecast.snow not available if amount is zero - * The API always returns in millimeters - */ - if (forecast.hasOwnProperty("snow") && !isNaN(forecast.snow)) { - weather.snow = forecast.snow; + if (weatherData && this.onDataCallback) { + this.onDataCallback(weatherData); + } + } catch (error) { + Log.error("[openweathermap] Error processing weather data:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: error.message, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); } - - weather.precipitationAmount = weather.rain + weather.snow; - weather.precipitationProbability = forecast.pop ? forecast.pop * 100 : undefined; - - days.push(weather); } + } - return days; - }, - - /* - * Fetch One Call forecast information (available for free subscription). - * Factors in timezone offsets. - * Minutely forecasts are excluded for the moment, see getParams(). - */ - fetchOnecall (data) { + #generateWeatherObjectsFromOnecall (data) { let precip = false; - // get current weather, if requested - const current = new WeatherObject(); + // Get current weather + const current = {}; if (data.hasOwnProperty("current")) { - current.date = moment.unix(data.current.dt).utcOffset(data.timezone_offset / 60); + const timezoneOffset = data.timezone_offset / 60; + current.date = weatherUtils.applyTimezoneOffset(new Date(data.current.dt * 1000), timezoneOffset); current.windSpeed = data.current.wind_speed; current.windFromDirection = data.current.wind_deg; - current.sunrise = moment.unix(data.current.sunrise).utcOffset(data.timezone_offset / 60); - current.sunset = moment.unix(data.current.sunset).utcOffset(data.timezone_offset / 60); + current.sunrise = weatherUtils.applyTimezoneOffset(new Date(data.current.sunrise * 1000), timezoneOffset); + current.sunset = weatherUtils.applyTimezoneOffset(new Date(data.current.sunset * 1000), timezoneOffset); current.temperature = data.current.temp; - current.weatherType = this.convertWeatherType(data.current.weather[0].icon); + current.weatherType = weatherUtils.convertWeatherType(data.current.weather[0].icon); current.humidity = data.current.humidity; - current.uv_index = data.current.uvi; + current.uvIndex = data.current.uvi; + + precip = false; if (data.current.hasOwnProperty("rain") && !isNaN(data.current.rain["1h"])) { current.rain = data.current.rain["1h"]; precip = true; @@ -299,21 +166,22 @@ WeatherProvider.register("openweathermap", { current.feelsLikeTemp = data.current.feels_like; } - let weather = new WeatherObject(); - - // get hourly weather, if requested + // Get hourly weather const hours = []; if (data.hasOwnProperty("hourly")) { + const timezoneOffset = data.timezone_offset / 60; for (const hour of data.hourly) { - weather.date = moment.unix(hour.dt).utcOffset(data.timezone_offset / 60); + const weather = {}; + weather.date = weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset); weather.temperature = hour.temp; weather.feelsLikeTemp = hour.feels_like; weather.humidity = hour.humidity; weather.windSpeed = hour.wind_speed; weather.windFromDirection = hour.wind_deg; - weather.weatherType = this.convertWeatherType(hour.weather[0].icon); - weather.precipitationProbability = hour.pop ? hour.pop * 100 : undefined; - weather.uv_index = hour.uvi; + weather.weatherType = weatherUtils.convertWeatherType(hour.weather[0].icon); + weather.precipitationProbability = hour.pop !== undefined ? hour.pop * 100 : undefined; + weather.uvIndex = hour.uvi; + precip = false; if (hour.hasOwnProperty("rain") && !isNaN(hour.rain["1h"])) { weather.rain = hour.rain["1h"]; @@ -328,25 +196,27 @@ WeatherProvider.register("openweathermap", { } hours.push(weather); - weather = new WeatherObject(); } } - // get daily weather, if requested + // Get daily weather const days = []; if (data.hasOwnProperty("daily")) { + const timezoneOffset = data.timezone_offset / 60; for (const day of data.daily) { - weather.date = moment.unix(day.dt).utcOffset(data.timezone_offset / 60); - weather.sunrise = moment.unix(day.sunrise).utcOffset(data.timezone_offset / 60); - weather.sunset = moment.unix(day.sunset).utcOffset(data.timezone_offset / 60); + const weather = {}; + weather.date = weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset); + weather.sunrise = weatherUtils.applyTimezoneOffset(new Date(day.sunrise * 1000), timezoneOffset); + weather.sunset = weatherUtils.applyTimezoneOffset(new Date(day.sunset * 1000), timezoneOffset); weather.minTemperature = day.temp.min; weather.maxTemperature = day.temp.max; weather.humidity = day.humidity; weather.windSpeed = day.wind_speed; weather.windFromDirection = day.wind_deg; - weather.weatherType = this.convertWeatherType(day.weather[0].icon); - weather.precipitationProbability = day.pop ? day.pop * 100 : undefined; - weather.uv_index = day.uvi; + weather.weatherType = weatherUtils.convertWeatherType(day.weather[0].icon); + weather.precipitationProbability = day.pop !== undefined ? day.pop * 100 : undefined; + weather.uvIndex = day.uvi; + precip = false; if (!isNaN(day.rain)) { weather.rain = day.rain; @@ -361,52 +231,23 @@ WeatherProvider.register("openweathermap", { } days.push(weather); - weather = new WeatherObject(); } } - return { current: current, hours: hours, days: days }; - }, - - /* - * Convert the OpenWeatherMap icons to a more usable name. - */ - convertWeatherType (weatherType) { - const weatherTypes = { - "01d": "day-sunny", - "02d": "day-cloudy", - "03d": "cloudy", - "04d": "cloudy-windy", - "09d": "showers", - "10d": "rain", - "11d": "thunderstorm", - "13d": "snow", - "50d": "fog", - "01n": "night-clear", - "02n": "night-cloudy", - "03n": "night-cloudy", - "04n": "night-cloudy", - "09n": "night-showers", - "10n": "night-rain", - "11n": "night-thunderstorm", - "13n": "night-snow", - "50n": "night-alt-cloudy-windy" - }; + return { current, hours, days }; + } - return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; - }, + #getUrl () { + return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.#getParams(); + } - /* - * getParams(compliments) - * Generates an url with api parameters based on the config. - * - * return String - URL params. - */ - getParams () { + #getParams () { let params = "?"; + if (this.config.weatherEndpoint === "/onecall") { params += `lat=${this.config.lat}`; params += `&lon=${this.config.lon}`; + if (this.config.type === "current") { params += "&exclude=minutely,hourly,daily"; } else if (this.config.type === "hourly") { @@ -422,20 +263,14 @@ WeatherProvider.register("openweathermap", { params += `id=${this.config.locationID}`; } else if (this.config.location) { params += `q=${this.config.location}`; - } else if (this.firstEvent && this.firstEvent.geo) { - params += `lat=${this.firstEvent.geo.lat}&lon=${this.firstEvent.geo.lon}`; - } else if (this.firstEvent && this.firstEvent.location) { - params += `q=${this.firstEvent.location}`; - } else { - // TODO hide doesn't exist! - this.hide(this.config.animationSpeed, { lockString: this.identifier }); - return; } - params += "&units=metric"; // WeatherProviders should use metric internally and use the units only for when displaying data - params += `&lang=${this.config.lang}`; + params += "&units=metric"; + params += `&lang=${this.config.lang || "en"}`; params += `&APPID=${this.config.apiKey}`; return params; } -}); +} + +module.exports = OpenWeatherMapProvider; diff --git a/defaultmodules/weather/providers/overrideWrapper.js b/defaultmodules/weather/providers/overrideWrapper.js deleted file mode 100644 index 61afa10176..0000000000 --- a/defaultmodules/weather/providers/overrideWrapper.js +++ /dev/null @@ -1,112 +0,0 @@ -/* global Class, WeatherObject */ - -/* - * Wrapper class to enable overrides of currentOverrideWeatherObject. - * - * Sits between the weather.js module and the provider implementations to allow us to - * combine the incoming data from the CURRENT_WEATHER_OVERRIDE notification with the - * existing data received from the current api provider. If no notifications have - * been received then the api provider's data is used. - * - * The intent is to allow partial WeatherObjects from local sensors to augment or - * replace the WeatherObjects from the api providers. - * - * This class shares the signature of WeatherProvider, and passes any methods not - * concerning the current weather directly to the api provider implementation that - * is currently in use. - */ -const OverrideWrapper = Class.extend({ - baseProvider: null, - providerName: "localWrapper", - notificationWeatherObject: null, - currentOverrideWeatherObject: null, - - init (baseProvider) { - this.baseProvider = baseProvider; - - // Binding the scope of current weather functions so any fetchData calls with - // setCurrentWeather nested in them call this classes implementation instead - // of the provider's default - this.baseProvider.setCurrentWeather = this.setCurrentWeather.bind(this); - this.baseProvider.currentWeather = this.currentWeather.bind(this); - }, - - /* Unchanged Api Provider Methods */ - - setConfig (config) { - this.baseProvider.setConfig(config); - }, - start () { - this.baseProvider.start(); - }, - fetchCurrentWeather () { - this.baseProvider.fetchCurrentWeather(); - }, - fetchWeatherForecast () { - this.baseProvider.fetchWeatherForecast(); - }, - fetchWeatherHourly () { - this.baseProvider.fetchWeatherHourly(); - }, - weatherForecast () { - this.baseProvider.weatherForecast(); - }, - weatherHourly () { - this.baseProvider.weatherHourly(); - }, - fetchedLocation () { - this.baseProvider.fetchedLocation(); - }, - setWeatherForecast (weatherForecastArray) { - this.baseProvider.setWeatherForecast(weatherForecastArray); - }, - setWeatherHourly (weatherHourlyArray) { - this.baseProvider.setWeatherHourly(weatherHourlyArray); - }, - setFetchedLocation (name) { - this.baseProvider.setFetchedLocation(name); - }, - updateAvailable () { - this.baseProvider.updateAvailable(); - }, - async fetchData (url, type = "json", requestHeaders = undefined, expectedResponseHeaders = undefined) { - this.baseProvider.fetchData(url, type, requestHeaders, expectedResponseHeaders); - }, - - /* Override Methods */ - - /** - * Override to return this scope's - * @returns {WeatherObject} The current weather object. May or may not contain overridden data. - */ - currentWeather () { - return this.currentOverrideWeatherObject; - }, - - /** - * Override to combine the overrideWeatherObject provided in the - * notificationReceived method with the currentOverrideWeatherObject provided by the - * api provider fetchData implementation. - * @param {WeatherObject} currentWeatherObject - the api provider weather object - */ - setCurrentWeather (currentWeatherObject) { - this.currentOverrideWeatherObject = Object.assign(currentWeatherObject, this.notificationWeatherObject); - }, - - /** - * Updates the overrideWeatherObject, calls setCurrentWeather to combine it with - * the existing current weather object provided by the base provider, and signals - * that an update is ready. - * @param {WeatherObject} payload - the weather object received from the CURRENT_WEATHER_OVERRIDE - * notification. Represents information to augment the - * existing currentOverrideWeatherObject with. - */ - notificationReceived (payload) { - this.notificationWeatherObject = payload; - - // setCurrentWeather combines the newly received notification weather with - // the existing weather object we return for current weather - this.setCurrentWeather(this.currentOverrideWeatherObject); - this.updateAvailable(); - } -}); diff --git a/defaultmodules/weather/providers/pirateweather.js b/defaultmodules/weather/providers/pirateweather.js index 73760578f2..0e5b1f31a6 100644 --- a/defaultmodules/weather/providers/pirateweather.js +++ b/defaultmodules/weather/providers/pirateweather.js @@ -1,114 +1,244 @@ -/* global WeatherProvider, WeatherObject */ - -/* - * This class is a provider for Pirate Weather, it is a replacement for Dark Sky (same api), - * see http://pirateweather.net/en/latest/ - */ -WeatherProvider.register("pirateweather", { - - /* - * Set the name of the provider. - * Not strictly required, but helps for debugging. - */ - providerName: "pirateweather", - - // Set the default config properties that is specific to this provider - defaults: { - useCorsProxy: true, - apiBase: "https://api.pirateweather.net", - weatherEndpoint: "/forecast", - apiKey: "", - lat: 0, - lon: 0 - }, - - async fetchCurrentWeather () { - try { - const data = await this.fetchData(this.getUrl()); - if (!data || !data.currently || typeof data.currently.temperature === "undefined") { - throw new Error("No usable data received from Pirate Weather API."); +const Log = require("logger"); +const HTTPFetcher = require("#http_fetcher"); + +class PirateweatherProvider { + constructor (config) { + this.config = { + apiBase: "https://api.pirateweather.net", + weatherEndpoint: "/forecast", + apiKey: "", + lat: 0, + lon: 0, + type: "current", + updateInterval: 10 * 60 * 1000, + units: "us", + lang: "en", + ...config + }; + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + } + + setCallbacks (onDataCallback, onErrorCallback) { + this.onDataCallback = onDataCallback; + this.onErrorCallback = onErrorCallback; + } + + async initialize () { + if (!this.config.apiKey) { + Log.error("[pirateweather] No API key configured"); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "API key required", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); } + return; + } + + this.initializeFetcher(); + } + + initializeFetcher () { + const url = this.getUrl(); + + this.fetcher = new HTTPFetcher(url, { + reloadInterval: this.config.updateInterval, + headers: { + "Cache-Control": "no-cache", + Accept: "application/json" + }, + logContext: "weatherprovider.pirateweather" + }); + + this.fetcher.on("response", async (response) => { + try { + const data = await response.json(); + this.handleResponse(data); + } catch (error) { + Log.error("[pirateweather] Parse error:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Failed to parse API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + } + }); + + this.fetcher.on("error", (errorInfo) => { + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); + } + }); + } - const currentWeather = this.generateWeatherDayFromCurrentWeather(data); - this.setCurrentWeather(currentWeather); - } catch (error) { - Log.error("Could not load data ... ", error); - } finally { - this.updateAvailable(); + handleResponse (data) { + if (!data || (!data.currently && !data.daily && !data.hourly)) { + Log.error("[pirateweather] No usable data received"); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "No usable data in API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + return; + } + + let weatherData = null; + + switch (this.config.type) { + case "current": + weatherData = this.generateCurrentWeather(data); + break; + case "forecast": + case "daily": + weatherData = this.generateForecast(data); + break; + case "hourly": + weatherData = this.generateHourly(data); + break; + default: + Log.error(`[pirateweather] Unknown weather type: ${this.config.type}`); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: `Unknown weather type: ${this.config.type}`, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + return; + + } + + if (weatherData && this.onDataCallback) { + this.onDataCallback(weatherData); } - }, + } + + generateCurrentWeather (data) { + if (!data.currently || typeof data.currently.temperature === "undefined") { + return null; + } + + const current = { + date: new Date(), + humidity: data.currently.humidity != null ? parseFloat(data.currently.humidity) * 100 : null, + temperature: parseFloat(data.currently.temperature), + feelsLikeTemp: data.currently.apparentTemperature != null ? parseFloat(data.currently.apparentTemperature) : null, + windSpeed: data.currently.windSpeed != null ? parseFloat(data.currently.windSpeed) : null, + windFromDirection: data.currently.windBearing || null, + weatherType: this.convertWeatherType(data.currently.icon), + sunrise: null, + sunset: null + }; - async fetchWeatherForecast () { - try { - const data = await this.fetchData(this.getUrl()); - if (!data || !data.daily || !data.daily.data.length) { - throw new Error("No usable data received from Pirate Weather API."); + // Add sunrise/sunset from daily data if available + if (data.daily && data.daily.data && data.daily.data.length > 0) { + const today = data.daily.data[0]; + if (today.sunriseTime) { + current.sunrise = new Date(today.sunriseTime * 1000); + } + if (today.sunsetTime) { + current.sunset = new Date(today.sunsetTime * 1000); } + } + + return current; + } - const forecast = this.generateWeatherObjectsFromForecast(data.daily.data); - this.setWeatherForecast(forecast); - } catch (error) { - Log.error("Could not load data ... ", error); - } finally { - this.updateAvailable(); + generateForecast (data) { + if (!data.daily || !data.daily.data || !data.daily.data.length) { + return []; } - }, - // Create a URL from the config and base URL. - getUrl () { - return `${this.config.apiBase}${this.config.weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=si&lang=${this.config.lang}`; - }, - - // Implement WeatherDay generator. - generateWeatherDayFromCurrentWeather (currentWeatherData) { - const currentWeather = new WeatherObject(); - - currentWeather.date = moment(); - currentWeather.humidity = parseFloat(currentWeatherData.currently.humidity); - currentWeather.temperature = parseFloat(currentWeatherData.currently.temperature); - currentWeather.windSpeed = parseFloat(currentWeatherData.currently.windSpeed); - currentWeather.windFromDirection = currentWeatherData.currently.windBearing; - currentWeather.weatherType = this.convertWeatherType(currentWeatherData.currently.icon); - currentWeather.sunrise = moment.unix(currentWeatherData.daily.data[0].sunriseTime); - currentWeather.sunset = moment.unix(currentWeatherData.daily.data[0].sunsetTime); - - return currentWeather; - }, - - generateWeatherObjectsFromForecast (forecasts) { const days = []; - for (const forecast of forecasts) { - const weather = new WeatherObject(); - - weather.date = moment.unix(forecast.time); - weather.minTemperature = forecast.temperatureMin; - weather.maxTemperature = forecast.temperatureMax; - weather.weatherType = this.convertWeatherType(forecast.icon); - weather.snow = 0; - weather.rain = 0; + for (const forecast of data.daily.data) { + const day = { + date: new Date(forecast.time * 1000), + minTemperature: forecast.temperatureMin != null ? parseFloat(forecast.temperatureMin) : null, + maxTemperature: forecast.temperatureMax != null ? parseFloat(forecast.temperatureMax) : null, + weatherType: this.convertWeatherType(forecast.icon), + snow: 0, + rain: 0, + precipitationAmount: 0, + precipitationProbability: forecast.precipProbability != null ? parseFloat(forecast.precipProbability) * 100 : null + }; + // Handle precipitation let precip = 0; if (forecast.hasOwnProperty("precipAccumulation")) { - precip = forecast.precipAccumulation * 10; + precip = forecast.precipAccumulation * 10; // cm to mm } - weather.precipitationAmount = precip; - if (forecast.hasOwnProperty("precipType")) { + day.precipitationAmount = precip; + + if (forecast.precipType) { if (forecast.precipType === "snow") { - weather.snow = precip; + day.snow = precip; } else { - weather.rain = precip; + day.rain = precip; } } - days.push(weather); + days.push(day); } return days; - }, + } + + generateHourly (data) { + if (!data.hourly || !data.hourly.data || !data.hourly.data.length) { + return []; + } + + const hours = []; + + for (const forecast of data.hourly.data) { + const hour = { + date: new Date(forecast.time * 1000), + temperature: forecast.temperature !== undefined ? parseFloat(forecast.temperature) : null, + feelsLikeTemp: forecast.apparentTemperature !== undefined ? parseFloat(forecast.apparentTemperature) : null, + weatherType: this.convertWeatherType(forecast.icon), + windSpeed: forecast.windSpeed !== undefined ? parseFloat(forecast.windSpeed) : null, + windFromDirection: forecast.windBearing || null, + precipitationProbability: forecast.precipProbability ? parseFloat(forecast.precipProbability) * 100 : null, + snow: 0, + rain: 0, + precipitationAmount: 0 + }; + + // Handle precipitation + let precip = 0; + if (forecast.hasOwnProperty("precipAccumulation")) { + precip = forecast.precipAccumulation * 10; // cm to mm + } + + hour.precipitationAmount = precip; + + if (forecast.precipType) { + if (forecast.precipType === "snow") { + hour.snow = precip; + } else { + hour.rain = precip; + } + } + + hours.push(hour); + } + + return hours; + } + + getUrl () { + const apiBase = this.config.apiBase || "https://api.pirateweather.net"; + const weatherEndpoint = this.config.weatherEndpoint || "/forecast"; + const units = this.config.units || "us"; + const lang = this.config.lang || "en"; + return `${apiBase}${weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=${units}&lang=${lang}`; + } - // Map icons from Pirate Weather to our icons. convertWeatherType (weatherType) { const weatherTypes = { "clear-day": "day-sunny", @@ -123,6 +253,20 @@ WeatherProvider.register("pirateweather", { "partly-cloudy-night": "night-cloudy" }; - return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + return weatherTypes[weatherType] || null; + } + + start () { + if (this.fetcher) { + this.fetcher.startPeriodicFetch(); + } + } + + stop () { + if (this.fetcher) { + this.fetcher.clearTimer(); + } } -}); +} + +module.exports = PirateweatherProvider; diff --git a/defaultmodules/weather/providers/smhi.js b/defaultmodules/weather/providers/smhi.js index bcb873a9af..08663d918f 100644 --- a/defaultmodules/weather/providers/smhi.js +++ b/defaultmodules/weather/providers/smhi.js @@ -1,213 +1,267 @@ -/* global WeatherProvider, WeatherObject */ +const Log = require("logger"); +const { getSunTimes, isDayTime, validateCoordinates } = require("../provider-utils"); +const HTTPFetcher = require("#http_fetcher"); -/* - * This class is a provider for SMHI (Sweden only). - * Metric system is the only supported unit, - * see https://www.smhi.se/ +/** + * Server-side weather provider for SMHI (Swedish Meteorological and Hydrological Institute) + * Sweden only, metric system + * API: https://opendata.smhi.se/apidocs/metfcst/ */ -WeatherProvider.register("smhi", { - providerName: "SMHI", - - // Set the default config properties that is specific to this provider - defaults: { - lat: 0, // Cant have more than 6 digits - lon: 0, // Cant have more than 6 digits - precipitationValue: "pmedian", - location: false - }, - - /** - * Implements method in interface for fetching current weather. - */ - fetchCurrentWeather () { - this.fetchData(this.getURL()) - .then((data) => { - const closest = this.getClosestToCurrentTime(data.timeSeries); - const coordinates = this.resolveCoordinates(data); - const weatherObject = this.convertWeatherDataToObject(closest, coordinates); - this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); - this.setCurrentWeather(weatherObject); - }) - .catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`)) - .finally(() => this.updateAvailable()); - }, - - /** - * Implements method in interface for fetching a multi-day forecast. - */ - fetchWeatherForecast () { - this.fetchData(this.getURL()) - .then((data) => { - const coordinates = this.resolveCoordinates(data); - const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates); - this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); - this.setWeatherForecast(weatherObjects); - }) - .catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`)) - .finally(() => this.updateAvailable()); - }, - - /** - * Implements method in interface for fetching hourly forecasts. - */ - fetchWeatherHourly () { - this.fetchData(this.getURL()) - .then((data) => { - const coordinates = this.resolveCoordinates(data); - const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates, "hour"); - this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); - this.setWeatherHourly(weatherObjects); - }) - .catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`)) - .finally(() => this.updateAvailable()); - }, - - /** - * Overrides method for setting config with checks for the precipitationValue being unset or invalid - * @param {object} config The configuration object - */ - setConfig (config) { - this.config = config; - if (!config.precipitationValue || ["pmin", "pmean", "pmedian", "pmax"].indexOf(config.precipitationValue) === -1) { - Log.log(`[weatherprovider.smhi] invalid or not set: ${config.precipitationValue}`); - config.precipitationValue = this.defaults.precipitationValue; +class SMHIProvider { + constructor (config) { + this.config = { + lat: 0, + lon: 0, + precipitationValue: "pmedian", // pmin, pmean, pmedian, pmax + type: "current", + updateInterval: 5 * 60 * 1000, + ...config + }; + + // Validate precipitationValue + if (!["pmin", "pmean", "pmedian", "pmax"].includes(this.config.precipitationValue)) { + Log.warn(`[smhi] Invalid precipitationValue: ${this.config.precipitationValue}, using pmedian`); + this.config.precipitationValue = "pmedian"; } - }, - - /** - * Of all the times returned find out which one is closest to the current time, should be the first if the data isn't old. - * @param {object[]} times Array of time objects - * @returns {object} The weatherdata closest to the current time - */ - getClosestToCurrentTime (times) { - let now = moment(); - let minDiff = undefined; - for (const time of times) { - let diff = Math.abs(moment(time.validTime).diff(now)); - if (!minDiff || diff < Math.abs(moment(minDiff.validTime).diff(now))) { - minDiff = time; + + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + } + + async initialize () { + try { + // SMHI requires max 6 decimal places + validateCoordinates(this.config, 6); + this.#initializeFetcher(); + } catch (error) { + Log.error("[smhi] Initialization failed:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: error.message, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); } } - return minDiff; - }, - - /** - * Get the forecast url for the configured coordinates - * @returns {string} the url for the specified coordinates - */ - getURL () { - const formatter = new Intl.NumberFormat("en-US", { - minimumFractionDigits: 6, - maximumFractionDigits: 6 + } + + setCallbacks (onData, onError) { + this.onDataCallback = onData; + this.onErrorCallback = onError; + } + + start () { + if (this.fetcher) { + this.fetcher.startPeriodicFetch(); + } + } + + stop () { + if (this.fetcher) { + this.fetcher.clearTimer(); + } + } + + #initializeFetcher () { + const url = this.#getURL(); + + this.fetcher = new HTTPFetcher(url, { + reloadInterval: this.config.updateInterval, + logContext: "weatherprovider.smhi" }); - const lon = formatter.format(this.config.lon); - const lat = formatter.format(this.config.lat); - return `https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/${lon}/lat/${lat}/data.json`; - }, - - /** - * Calculates the apparent temperature based on known atmospheric data. - * @param {object} weatherData Weatherdata to use for the calculation - * @returns {number} The apparent temperature - */ - calculateApparentTemperature (weatherData) { - const Ta = this.paramValue(weatherData, "t"); - const rh = this.paramValue(weatherData, "r"); - const ws = this.paramValue(weatherData, "ws"); - const p = (rh / 100) * 6.105 * Math.E * ((17.27 * Ta) / (237.7 + Ta)); - return Ta + 0.33 * p - 0.7 * ws - 4; - }, - - /** - * Converts the returned data into a WeatherObject with required properties set for both current weather and forecast. - * The returned units is always in metric system. - * Requires coordinates to determine if its daytime or nighttime to know which icon to use and also to set sunrise and sunset. - * @param {object} weatherData Weatherdata to convert - * @param {object} coordinates Coordinates of the locations of the weather - * @returns {WeatherObject} The converted weatherdata at the specified location - */ - convertWeatherDataToObject (weatherData, coordinates) { - let currentWeather = new WeatherObject(); - - currentWeather.date = moment(weatherData.validTime); - currentWeather.updateSunTime(coordinates.lat, coordinates.lon); - currentWeather.humidity = this.paramValue(weatherData, "r"); - currentWeather.temperature = this.paramValue(weatherData, "t"); - currentWeather.windSpeed = this.paramValue(weatherData, "ws"); - currentWeather.windFromDirection = this.paramValue(weatherData, "wd"); - currentWeather.weatherType = this.convertWeatherType(this.paramValue(weatherData, "Wsymb2"), currentWeather.isDayTime()); - currentWeather.feelsLikeTemp = this.calculateApparentTemperature(weatherData); - - /* - * Determine the precipitation amount and category and update the - * weatherObject with it, the value type to use can be configured or uses - * median as default. - */ - let precipitationValue = this.paramValue(weatherData, this.config.precipitationValue); - switch (this.paramValue(weatherData, "pcat")) { - // 0 = No precipitation + this.fetcher.on("response", async (response) => { + try { + const data = await response.json(); + this.#handleResponse(data); + } catch (error) { + Log.error("[smhi] Failed to parse JSON:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Failed to parse API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + } + }); + + this.fetcher.on("error", (errorInfo) => { + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); + } + }); + } + + #handleResponse (data) { + try { + if (!data.timeSeries || !Array.isArray(data.timeSeries)) { + throw new Error("Invalid weather data"); + } + + const coordinates = this.#resolveCoordinates(data); + let weatherData; + + switch (this.config.type) { + case "current": + weatherData = this.#generateCurrentWeather(data.timeSeries, coordinates); + break; + case "forecast": + case "daily": + weatherData = this.#generateForecast(data.timeSeries, coordinates); + break; + case "hourly": + weatherData = this.#generateHourly(data.timeSeries, coordinates); + break; + default: + Log.error(`[smhi] Unknown weather type: ${this.config.type}`); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: `Unknown weather type: ${this.config.type}`, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + return; + } + + if (this.onDataCallback) { + this.onDataCallback(weatherData); + } + } catch (error) { + Log.error("[smhi] Error processing weather data:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: error.message, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + } + } + + #generateCurrentWeather (timeSeries, coordinates) { + const closest = this.#getClosestToCurrentTime(timeSeries); + return this.#convertWeatherDataToObject(closest, coordinates); + } + + #generateForecast (timeSeries, coordinates) { + const filled = this.#fillInGaps(timeSeries); + return this.#convertWeatherDataGroupedBy(filled, coordinates, "day"); + } + + #generateHourly (timeSeries, coordinates) { + const filled = this.#fillInGaps(timeSeries); + return this.#convertWeatherDataGroupedBy(filled, coordinates, "hour"); + } + + #getClosestToCurrentTime (times) { + const now = new Date(); + let minDiff = null; + let closest = times[0]; + + for (const time of times) { + const validTime = new Date(time.validTime); + const diff = Math.abs(validTime - now); + + if (minDiff === null || diff < minDiff) { + minDiff = diff; + closest = time; + } + } + + return closest; + } + + #convertWeatherDataToObject (weatherData, coordinates) { + const date = new Date(weatherData.validTime); + const { sunrise, sunset } = getSunTimes(date, coordinates.lat, coordinates.lon); + const isDay = isDayTime(date, sunrise, sunset); + + const current = { + date: date, + humidity: this.#paramValue(weatherData, "r"), + temperature: this.#paramValue(weatherData, "t"), + windSpeed: this.#paramValue(weatherData, "ws"), + windFromDirection: this.#paramValue(weatherData, "wd"), + weatherType: this.#convertWeatherType(this.#paramValue(weatherData, "Wsymb2"), isDay), + feelsLikeTemp: this.#calculateApparentTemperature(weatherData), + sunrise: sunrise, + sunset: sunset, + snow: 0, + rain: 0, + precipitationAmount: 0 + }; + + // Determine precipitation amount and category + const precipitationValue = this.#paramValue(weatherData, this.config.precipitationValue); + const pcat = this.#paramValue(weatherData, "pcat"); + + switch (pcat) { case 1: // Snow - currentWeather.snow += precipitationValue; - currentWeather.precipitationAmount += precipitationValue; + current.snow = precipitationValue; + current.precipitationAmount = precipitationValue; break; - case 2: // Snow and rain, treat it as 50/50 snow and rain - currentWeather.snow += precipitationValue / 2; - currentWeather.rain += precipitationValue / 2; - currentWeather.precipitationAmount += precipitationValue; + case 2: // Snow and rain (50/50 split) + current.snow = precipitationValue / 2; + current.rain = precipitationValue / 2; + current.precipitationAmount = precipitationValue; break; case 3: // Rain case 4: // Drizzle case 5: // Freezing rain case 6: // Freezing drizzle - currentWeather.rain += precipitationValue; - currentWeather.precipitationAmount += precipitationValue; + current.rain = precipitationValue; + current.precipitationAmount = precipitationValue; break; + // case 0: No precipitation - defaults already set to 0 } - return currentWeather; - }, - - /** - * Takes all the data points and converts it to one WeatherObject per day. - * @param {object[]} allWeatherData Array of weatherdata - * @param {object} coordinates Coordinates of the locations of the weather - * @param {string} groupBy The interval to use for grouping the data (day, hour) - * @returns {WeatherObject[]} Array of weather objects - */ - convertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = "day") { - let currentWeather; - let result = []; - - let allWeatherObjects = this.fillInGaps(allWeatherData).map((weatherData) => this.convertWeatherDataToObject(weatherData, coordinates)); + return current; + } + + #convertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = "day") { + const result = []; + let currentWeather = null; let dayWeatherTypes = []; + const allWeatherObjects = allWeatherData.map((data) => this.#convertWeatherDataToObject(data, coordinates)); + for (const weatherObject of allWeatherObjects) { - //If its the first object or if a day/hour change we need to reset the summary object - if (!currentWeather || !currentWeather.date.isSame(weatherObject.date, groupBy)) { - currentWeather = new WeatherObject(); + const objDate = new Date(weatherObject.date); + + // Check if we need a new group (day or hour change) + const needNewGroup = !currentWeather || !this.#isSamePeriod(currentWeather.date, objDate, groupBy); + + if (needNewGroup) { + currentWeather = { + date: objDate, + temperature: weatherObject.temperature, + minTemperature: Infinity, + maxTemperature: -Infinity, + snow: 0, + rain: 0, + precipitationAmount: 0, + sunrise: weatherObject.sunrise, + sunset: weatherObject.sunset + }; dayWeatherTypes = []; - currentWeather.temperature = weatherObject.temperature; - currentWeather.date = weatherObject.date; - currentWeather.minTemperature = Infinity; - currentWeather.maxTemperature = -Infinity; - currentWeather.snow = 0; - currentWeather.rain = 0; - currentWeather.precipitationAmount = 0; result.push(currentWeather); } - //Keep track of what icons have been used for each hour of daytime and use the middle one for the forecast - if (weatherObject.isDayTime()) { + // Track weather types during daytime + const { sunrise: daySunrise, sunset: daySunset } = getSunTimes(objDate, coordinates.lat, coordinates.lon); + const isDay = isDayTime(objDate, daySunrise, daySunset); + + if (isDay) { dayWeatherTypes.push(weatherObject.weatherType); } + + // Use median weather type from daytime hours if (dayWeatherTypes.length > 0) { currentWeather.weatherType = dayWeatherTypes[Math.floor(dayWeatherTypes.length / 2)]; } else { currentWeather.weatherType = weatherObject.weatherType; } - //All other properties is either a sum, min or max of each hour + // Aggregate min/max and precipitation currentWeather.minTemperature = Math.min(currentWeather.minTemperature, weatherObject.temperature); currentWeather.maxTemperature = Math.max(currentWeather.maxTemperature, weatherObject.temperature); currentWeather.snow += weatherObject.snow; @@ -216,116 +270,128 @@ WeatherProvider.register("smhi", { } return result; - }, - - /** - * Resolve coordinates from the response data (probably preferably to use - * this if it's not matching the config values exactly) - * @param {object} data Response data from the weather service - * @returns {{lon, lat}} the lat/long coordinates of the data - */ - resolveCoordinates (data) { - return { lat: data.geometry.coordinates[0][1], lon: data.geometry.coordinates[0][0] }; - }, - - /** - * The distance between the data points is increasing in the data the more distant the prediction is. - * Find these gaps and fill them with the previous hours data to make the data returned a complete set. - * @param {object[]} data Response data from the weather service - * @returns {object[]} Given data with filled gaps - */ - fillInGaps (data) { - let result = []; + } + + #isSamePeriod (date1, date2, groupBy) { + if (groupBy === "hour") { + return date1.getFullYear() === date2.getFullYear() + && date1.getMonth() === date2.getMonth() + && date1.getDate() === date2.getDate() + && date1.getHours() === date2.getHours(); + } else { // day + return date1.getFullYear() === date2.getFullYear() + && date1.getMonth() === date2.getMonth() + && date1.getDate() === date2.getDate(); + } + } + + #fillInGaps (data) { + if (data.length === 0) return []; + + const result = []; + result.push(data[0]); // Keep first data point + for (let i = 1; i < data.length; i++) { - let to = moment(data[i].validTime); - let from = moment(data[i - 1].validTime); - let hours = moment.duration(to.diff(from)).asHours(); - // For each hour add a datapoint but change the validTime - for (let j = 0; j < hours; j++) { - let current = Object.assign({}, data[i]); - current.validTime = from.clone().add(j, "hours").toISOString(); + const from = new Date(data[i - 1].validTime); + const to = new Date(data[i].validTime); + const hours = Math.floor((to - from) / (1000 * 60 * 60)); + + // Fill gaps with previous data point (start at j=1 since j=0 is already pushed) + for (let j = 1; j < hours; j++) { + const current = { ...data[i - 1] }; + const newTime = new Date(from); + newTime.setHours(from.getHours() + j); + current.validTime = newTime.toISOString(); result.push(current); } + + // Push original data point + result.push(data[i]); } + return result; - }, - - /** - * Helper method to get a property from the returned data set. - * @param {object} currentWeatherData Weatherdata to get from - * @param {string} name The name of the property - * @returns {string} The value of the property in the weatherdata - */ - paramValue (currentWeatherData, name) { - return currentWeatherData.parameters.filter((p) => p.name === name).flatMap((p) => p.values)[0]; - }, - - /** - * Map the icon value from SMHI to an icon that MagicMirror² understands. - * Uses different icons depending on if its daytime or nighttime. - * SMHI's description of what the numeric value means is the comment after the case. - * @param {number} input The SMHI icon value - * @param {boolean} isDayTime True if the icon should be for daytime, false for nighttime - * @returns {string} The icon name for the MagicMirror - */ - convertWeatherType (input, isDayTime) { + } + + #resolveCoordinates (data) { + // SMHI returns coordinates in [lon, lat] format + // Fall back to config if response structure is unexpected + if (data?.geometry?.coordinates?.[0] && Array.isArray(data.geometry.coordinates[0]) && data.geometry.coordinates[0].length >= 2) { + return { + lat: data.geometry.coordinates[0][1], + lon: data.geometry.coordinates[0][0] + }; + } + + Log.warn("[smhi] Invalid coordinate structure in response, using config values"); + return { + lat: this.config.lat, + lon: this.config.lon + }; + } + + #calculateApparentTemperature (weatherData) { + const Ta = this.#paramValue(weatherData, "t"); + const rh = this.#paramValue(weatherData, "r"); + const ws = this.#paramValue(weatherData, "ws"); + const p = (rh / 100) * 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta)); + + return Ta + 0.33 * p - 0.7 * ws - 4; + } + + #paramValue (weatherData, name) { + const param = weatherData.parameters.find((p) => p.name === name); + return param ? param.values[0] : null; + } + + #convertWeatherType (input, isDayTime) { switch (input) { case 1: return isDayTime ? "day-sunny" : "night-clear"; // Clear sky case 2: return isDayTime ? "day-sunny-overcast" : "night-partly-cloudy"; // Nearly clear sky case 3: - return isDayTime ? "day-cloudy" : "night-cloudy"; // Variable cloudiness case 4: - return isDayTime ? "day-cloudy" : "night-cloudy"; // Halfclear sky + return isDayTime ? "day-cloudy" : "night-cloudy"; // Variable/halfclear cloudiness case 5: - return "cloudy"; // Cloudy sky case 6: - return "cloudy"; // Overcast + return "cloudy"; // Cloudy/overcast case 7: - return "fog"; // Fog + return "fog"; case 8: - return "showers"; // Light rain showers case 9: - return "showers"; // Moderate rain showers case 10: - return "showers"; // Heavy rain showers + return "showers"; // Light/moderate/heavy rain showers case 11: - return "thunderstorm"; // Thunderstorm + case 21: + return "thunderstorm"; case 12: - return "sleet"; // Light sleet showers case 13: - return "sleet"; // Moderate sleet showers case 14: - return "sleet"; // Heavy sleet showers - case 15: - return "snow"; // Light snow showers - case 16: - return "snow"; // Moderate snow showers - case 17: - return "snow"; // Heavy snow showers - case 18: - return "rain"; // Light rain - case 19: - return "rain"; // Moderate rain - case 20: - return "rain"; // Heavy rain - case 21: - return "thunderstorm"; // Thunder case 22: - return "sleet"; // Light sleet case 23: - return "sleet"; // Moderate sleet case 24: - return "sleet"; // Heavy sleet + return "sleet"; // Light/moderate/heavy sleet (showers) + case 15: + case 16: + case 17: case 25: - return "snow"; // Light snowfall case 26: - return "snow"; // Moderate snowfall case 27: - return "snow"; // Heavy snowfall + return "snow"; // Light/moderate/heavy snow (showers/fall) + case 18: + case 19: + case 20: + return "rain"; // Light/moderate/heavy rain default: - return ""; + return null; } } -}); + + #getURL () { + const lon = this.config.lon.toFixed(6); + const lat = this.config.lat.toFixed(6); + return `https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/${lon}/lat/${lat}/data.json`; + } +} + +module.exports = SMHIProvider; diff --git a/defaultmodules/weather/providers/ukmetofficedatahub.js b/defaultmodules/weather/providers/ukmetofficedatahub.js index 4f77a368a4..a015aca4a0 100644 --- a/defaultmodules/weather/providers/ukmetofficedatahub.js +++ b/defaultmodules/weather/providers/ukmetofficedatahub.js @@ -1,243 +1,282 @@ -/* global WeatherProvider, WeatherObject */ +const Log = require("logger"); +const { getSunTimes } = require("../provider-utils"); +const HTTPFetcher = require("#http_fetcher"); -/* - * This class is a provider for UK Met Office Data Hub (the replacement for their Data Point services). - * For more information on Data Hub, see https://www.metoffice.gov.uk/services/data/datapoint/notifications/weather-datahub - * Data available: - * Hourly data for next 2 days ("hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-hourly.pdf - * 3-hourly data for the next 7 days ("3hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-3-hourly.pdf - * Daily data for the next 7 days ("daily") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-daily.pdf - * - * NOTES - * This provider requires longitude/latitude coordinates, rather than a location ID (as with the previous Met Office provider) - * Provide the following in your config.js file: - * weatherProvider: "ukmetofficedatahub", - * apiBase: "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/", - * apiKey: "[YOUR API KEY]", - * lat: [LATITUDE (DECIMAL)], - * lon: [LONGITUDE (DECIMAL)] - * - * At time of writing, free accounts are limited to 360 requests a day per service (hourly, 3hourly, daily); take this in mind when - * setting your update intervals. For reference, 360 requests per day is once every 4 minutes. +/** + * UK Met Office Data Hub provider + * For more information: https://www.metoffice.gov.uk/services/data/datapoint/notifications/weather-datahub * - * Pay attention to the units of the supplied data from the Met Office - it is given in SI/metric units where applicable: - * - Temperatures are in degrees Celsius (°C) - * - Wind speeds are in metres per second (m/s) - * - Wind direction given in degrees (°) - * - Pressures are in Pascals (Pa) - * - Distances are in metres (m) - * - Probabilities and humidity are given as percentages (%) - * - Precipitation is measured in millimeters (mm) with rates per hour (mm/h) + * Data available: + * - Hourly data for next 2 days (for current weather) + * - 3-hourly data for next 7 days (for hourly forecasts) + * - Daily data for next 7 days (for daily forecasts) * - * See the PDFs linked above for more information on the data their corresponding units. + * Free accounts limited to 360 requests/day per service (once every 4 minutes) */ - -WeatherProvider.register("ukmetofficedatahub", { - // Set the name of the provider. - providerName: "UK Met Office (DataHub)", - - // Set the default config properties that is specific to this provider - defaults: { - apiBase: "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/", - apiKey: "", - lat: 0, - lon: 0 - }, - - // Build URL with query strings according to DataHub API (https://datahub.metoffice.gov.uk/docs/f/category/site-specific/type/site-specific/api-documentation#get-/point/hourly) - getUrl (forecastType) { - let queryStrings = "?"; - queryStrings += `latitude=${this.config.lat}`; - queryStrings += `&longitude=${this.config.lon}`; - queryStrings += `&includeLocationName=${true}`; - - // Return URL, making sure there is a trailing "/" in the base URL. - return this.config.apiBase + (this.config.apiBase.endsWith("/") ? "" : "/") + forecastType + queryStrings; - }, - - /* - * Build the list of headers for the request - * For DataHub requests, the API key/secret are sent in the headers rather than as query strings. - * Headers defined according to Data Hub API (https://datahub.metoffice.gov.uk/docs/f/category/site-specific/type/site-specific/api-documentation#get-/point/hourly) - */ - getHeaders () { - return { - accept: "application/json", - apikey: this.config.apiKey +class UkMetOfficeDataHubProvider { + constructor (config) { + this.config = { + apiBase: "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/", + apiKey: "", + lat: 0, + lon: 0, + type: "current", + updateInterval: 10 * 60 * 1000, + ...config }; - }, - - // Fetch data using supplied URL and request headers - async fetchWeather (url, headers) { - const response = await fetch(url, { headers: headers }); - - // Return JSON data - return response.json(); - }, - - // Fetch hourly forecast data (to use for current weather) - fetchCurrentWeather () { - this.fetchWeather(this.getUrl("hourly"), this.getHeaders()) - .then((data) => { - // Check data is usable - if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) { - - /* - * Did not receive usable new data. - * Maybe this needs a better check? - */ - Log.error("[weatherprovider.ukmetofficedatahub] Possibly bad current/hourly data?", data); - return; + + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + } + + setCallbacks (onDataCallback, onErrorCallback) { + this.onDataCallback = onDataCallback; + this.onErrorCallback = onErrorCallback; + } + + async initialize () { + if (!this.config.apiKey || this.config.apiKey === "YOUR_API_KEY_HERE") { + Log.error("[ukmetofficedatahub] No API key configured"); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "UK Met Office DataHub API key required. Get one at https://datahub.metoffice.gov.uk/", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + return; + } + + this.#initializeFetcher(); + } + + #initializeFetcher () { + const forecastType = this.#getForecastType(); + const url = this.#getUrl(forecastType); + + this.fetcher = new HTTPFetcher(url, { + reloadInterval: this.config.updateInterval, + headers: { + Accept: "application/json", + apikey: this.config.apiKey + }, + logContext: "weatherprovider.ukmetofficedatahub" + }); + + this.fetcher.on("response", async (response) => { + try { + const data = await response.json(); + this.#handleResponse(data); + } catch (error) { + Log.error("[ukmetofficedatahub] Parse error:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Failed to parse API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); } + } + }); + + this.fetcher.on("error", (errorInfo) => { + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); + } + }); + } + + #getForecastType () { + switch (this.config.type) { + case "hourly": + return "three-hourly"; + case "forecast": + case "daily": + return "daily"; + case "current": + default: + return "hourly"; + } + } + + #getUrl (forecastType) { + const base = this.config.apiBase.endsWith("/") ? this.config.apiBase : `${this.config.apiBase}/`; + const queryStrings = `?latitude=${this.config.lat}&longitude=${this.config.lon}&includeLocationName=true`; + return `${base}${forecastType}${queryStrings}`; + } - // Set location name - this.setFetchedLocation(`${data.features[0].properties.location.name}`); - - // Generate current weather data - const currentWeather = this.generateWeatherObjectFromCurrentWeather(data); - this.setCurrentWeather(currentWeather); - }) - - // Catch any error(s) - .catch((error) => Log.error(`[weatherprovider.ukmetofficedatahub] Could not load data: ${error.message}`)) - - // Let the module know there is data available - .finally(() => this.updateAvailable()); - }, - - // Create a WeatherObject using current weather data (data for the current hour) - generateWeatherObjectFromCurrentWeather (currentWeatherData) { - const currentWeather = new WeatherObject(); - - // Extract the actual forecasts - let forecastDataHours = currentWeatherData.features[0].properties.timeSeries; - - // Define now - let nowUtc = moment.utc(); - - // Find hour that contains the current time - for (let hour in forecastDataHours) { - let forecastTime = moment.utc(forecastDataHours[hour].time); - if (nowUtc.isSameOrAfter(forecastTime) && nowUtc.isBefore(moment(forecastTime.add(1, "h")))) { - currentWeather.date = forecastTime; - currentWeather.windSpeed = forecastDataHours[hour].windSpeed10m; - currentWeather.windFromDirection = forecastDataHours[hour].windDirectionFrom10m; - currentWeather.temperature = forecastDataHours[hour].screenTemperature; - currentWeather.minTemperature = forecastDataHours[hour].minScreenAirTemp; - currentWeather.maxTemperature = forecastDataHours[hour].maxScreenAirTemp; - currentWeather.weatherType = this.convertWeatherType(forecastDataHours[hour].significantWeatherCode); - currentWeather.humidity = forecastDataHours[hour].screenRelativeHumidity; - currentWeather.rain = forecastDataHours[hour].totalPrecipAmount; - currentWeather.snow = forecastDataHours[hour].totalSnowAmount; - currentWeather.precipitationProbability = forecastDataHours[hour].probOfPrecipitation; - currentWeather.feelsLikeTemp = forecastDataHours[hour].feelsLikeTemperature; - - /* - * Pass on full details, so they can be used in custom templates - * Note the units of the supplied data when using this (see top of file) - */ - currentWeather.rawData = forecastDataHours[hour]; + #handleResponse (data) { + if (!data || !data.features || !data.features[0] || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) { + Log.error("[ukmetofficedatahub] No usable data received"); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "No usable data in API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); } + return; } - /* - * Determine the sunrise/sunset times - (still) not supplied in UK Met Office data - * Passes {longitude, latitude} to SunCalc, could pass height to, but - * SunCalc.getTimes doesn't take that into account - */ - currentWeather.updateSunTime(this.config.lat, this.config.lon); - - return currentWeather; - }, - - // Fetch daily forecast data - fetchWeatherForecast () { - this.fetchWeather(this.getUrl("daily"), this.getHeaders()) - .then((data) => { - // Check data is usable - if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) { - - /* - * Did not receive usable new data. - * Maybe this needs a better check? - */ - Log.error("[weatherprovider.ukmetofficedatahub] Possibly bad forecast data?", data); - return; + let weatherData = null; + + switch (this.config.type) { + case "current": + weatherData = this.#generateCurrentWeather(data); + break; + case "forecast": + case "daily": + weatherData = this.#generateForecast(data); + break; + case "hourly": + weatherData = this.#generateHourly(data); + break; + default: + Log.error(`[ukmetofficedatahub] Unknown weather type: ${this.config.type}`); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: `Unknown weather type: ${this.config.type}`, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); } + return; + } + + if (weatherData && this.onDataCallback) { + this.onDataCallback(weatherData); + } + } + + #generateCurrentWeather (data) { + const timeSeries = data.features[0].properties.timeSeries; + const now = new Date(); + + // Find the hour that contains current time + for (const hour of timeSeries) { + const forecastTime = new Date(hour.time); + const oneHourLater = new Date(forecastTime.getTime() + 60 * 60 * 1000); + + if (now >= forecastTime && now < oneHourLater) { + const current = { + date: forecastTime, + temperature: hour.screenTemperature || null, + minTemperature: hour.minScreenAirTemp || null, + maxTemperature: hour.maxScreenAirTemp || null, + windSpeed: hour.windSpeed10m || null, + windFromDirection: hour.windDirectionFrom10m || null, + weatherType: this.#convertWeatherType(hour.significantWeatherCode), + humidity: hour.screenRelativeHumidity || null, + rain: hour.totalPrecipAmount || 0, + snow: hour.totalSnowAmount || 0, + precipitationAmount: (hour.totalPrecipAmount || 0) + (hour.totalSnowAmount || 0), + precipitationProbability: hour.probOfPrecipitation || null, + feelsLikeTemp: hour.feelsLikeTemperature || null, + sunrise: null, + sunset: null + }; + + // Calculate sunrise/sunset using SunCalc + const { sunrise, sunset } = getSunTimes(now, this.config.lat, this.config.lon); + current.sunrise = sunrise; + current.sunset = sunset; + + return current; + } + } + + // Fallback to first hour if no match found + const firstHour = timeSeries[0]; + const current = { + date: new Date(firstHour.time), + temperature: firstHour.screenTemperature || null, + windSpeed: firstHour.windSpeed10m || null, + windFromDirection: firstHour.windDirectionFrom10m || null, + weatherType: this.#convertWeatherType(firstHour.significantWeatherCode), + humidity: firstHour.screenRelativeHumidity || null, + rain: firstHour.totalPrecipAmount || 0, + snow: firstHour.totalSnowAmount || 0, + precipitationAmount: (firstHour.totalPrecipAmount || 0) + (firstHour.totalSnowAmount || 0), + precipitationProbability: firstHour.probOfPrecipitation || null, + feelsLikeTemp: firstHour.feelsLikeTemperature || null, + sunrise: null, + sunset: null + }; + + const { sunrise, sunset } = getSunTimes(now, this.config.lat, this.config.lon); + current.sunrise = sunrise; + current.sunset = sunset; + + return current; + } - // Set location name - this.setFetchedLocation(`${data.features[0].properties.location.name}`); - - // Generate the forecast data - const forecast = this.generateWeatherObjectsFromForecast(data); - this.setWeatherForecast(forecast); - }) - - // Catch any error(s) - .catch((error) => Log.error(`[weatherprovider.ukmetofficedatahub] Could not load data: ${error.message}`)) - - // Let the module know there is new data available - .finally(() => this.updateAvailable()); - }, - - // Create a WeatherObject for each day using daily forecast data - generateWeatherObjectsFromForecast (forecasts) { - const dailyForecasts = []; - - // Extract the actual forecasts - let forecastDataDays = forecasts.features[0].properties.timeSeries; - - // Define today - let today = moment.utc().startOf("date"); - - // Go through each day in the forecasts - for (let day in forecastDataDays) { - const forecastWeather = new WeatherObject(); - - // Get date of forecast - let forecastDate = moment.utc(forecastDataDays[day].time); - - // Check if forecast is for today or in the future (i.e., ignore yesterday's forecast) - if (forecastDate.isSameOrAfter(today)) { - forecastWeather.date = forecastDate; - forecastWeather.minTemperature = forecastDataDays[day].nightMinScreenTemperature; - forecastWeather.maxTemperature = forecastDataDays[day].dayMaxScreenTemperature; - - // Using daytime forecast values - forecastWeather.windSpeed = forecastDataDays[day].midday10MWindSpeed; - forecastWeather.windFromDirection = forecastDataDays[day].midday10MWindDirection; - forecastWeather.weatherType = this.convertWeatherType(forecastDataDays[day].daySignificantWeatherCode); - forecastWeather.precipitationProbability = forecastDataDays[day].dayProbabilityOfPrecipitation; - forecastWeather.temperature = forecastDataDays[day].dayMaxScreenTemperature; - forecastWeather.humidity = forecastDataDays[day].middayRelativeHumidity; - forecastWeather.rain = forecastDataDays[day].dayProbabilityOfRain; - forecastWeather.snow = forecastDataDays[day].dayProbabilityOfSnow; - forecastWeather.feelsLikeTemp = forecastDataDays[day].dayMaxFeelsLikeTemp; - - /* - * Pass on full details, so they can be used in custom templates - * Note the units of the supplied data when using this (see top of file) - */ - forecastWeather.rawData = forecastDataDays[day]; - - dailyForecasts.push(forecastWeather); + #generateForecast (data) { + const timeSeries = data.features[0].properties.timeSeries; + const days = []; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + for (const day of timeSeries) { + const forecastDate = new Date(day.time); + forecastDate.setHours(0, 0, 0, 0); + + // Only include today and future days + if (forecastDate >= today) { + days.push({ + date: new Date(day.time), + minTemperature: day.nightMinScreenTemperature || null, + maxTemperature: day.dayMaxScreenTemperature || null, + temperature: day.dayMaxScreenTemperature || null, + windSpeed: day.midday10MWindSpeed || null, + windFromDirection: day.midday10MWindDirection || null, + weatherType: this.#convertWeatherType(day.daySignificantWeatherCode), + humidity: day.middayRelativeHumidity || null, + rain: day.dayProbabilityOfRain || 0, + snow: day.dayProbabilityOfSnow || 0, + precipitationAmount: 0, + precipitationProbability: day.dayProbabilityOfPrecipitation || null, + feelsLikeTemp: day.dayMaxFeelsLikeTemp || null + }); } } - return dailyForecasts; - }, + return days; + } + + #generateHourly (data) { + const timeSeries = data.features[0].properties.timeSeries; + const hours = []; + + for (const hour of timeSeries) { + // 3-hourly data uses maxScreenAirTemp/minScreenAirTemp, not screenTemperature + const temp = hour.screenTemperature !== undefined + ? hour.screenTemperature + : (hour.maxScreenAirTemp !== undefined && hour.minScreenAirTemp !== undefined) + ? (hour.maxScreenAirTemp + hour.minScreenAirTemp) / 2 + : null; + + hours.push({ + date: new Date(hour.time), + temperature: temp, + windSpeed: hour.windSpeed10m || null, + windFromDirection: hour.windDirectionFrom10m || null, + weatherType: this.#convertWeatherType(hour.significantWeatherCode), + humidity: hour.screenRelativeHumidity || null, + rain: hour.totalPrecipAmount || 0, + snow: hour.totalSnowAmount || 0, + precipitationAmount: (hour.totalPrecipAmount || 0) + (hour.totalSnowAmount || 0), + precipitationProbability: hour.probOfPrecipitation || null, + feelsLikeTemp: hour.feelsLikeTemp || null + }); + } - // Set the fetched location name. - setFetchedLocation (name) { - this.fetchedLocationName = name; - }, + return hours; + } - /* - * Match the Met Office "significant weather code" to a weathericons.css icon - * Use: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264 - * and: https://erikflowers.github.io/weather-icons/ + /** + * Convert Met Office significant weather code to weathericons.css icon + * See: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264 + * @param {number} weatherType - Met Office weather code + * @returns {string|null} Weathericons.css icon name or null */ - convertWeatherType (weatherType) { + #convertWeatherType (weatherType) { const weatherTypes = { 0: "night-clear", 1: "day-sunny", @@ -271,6 +310,20 @@ WeatherProvider.register("ukmetofficedatahub", { 30: "thunderstorm" }; - return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + return weatherTypes[weatherType] || null; + } + + start () { + if (this.fetcher) { + this.fetcher.startPeriodicFetch(); + } + } + + stop () { + if (this.fetcher) { + this.fetcher.clearTimer(); + } } -}); +} + +module.exports = UkMetOfficeDataHubProvider; diff --git a/defaultmodules/weather/providers/weatherbit.js b/defaultmodules/weather/providers/weatherbit.js index 8423babb2b..834079ff4b 100644 --- a/defaultmodules/weather/providers/weatherbit.js +++ b/defaultmodules/weather/providers/weatherbit.js @@ -1,137 +1,210 @@ -/* global WeatherProvider, WeatherObject */ +const Log = require("logger"); +const HTTPFetcher = require("#http_fetcher"); -/* - * This class is a provider for Weatherbit, - * see https://www.weatherbit.io/ +/** + * Weatherbit weather provider + * See: https://www.weatherbit.io/ */ -WeatherProvider.register("weatherbit", { +class WeatherbitProvider { + constructor (config) { + this.config = { + apiBase: "https://api.weatherbit.io/v2.0", + apiKey: "", + lat: 0, + lon: 0, + type: "current", + updateInterval: 10 * 60 * 1000, + ...config + }; - /* - * Set the name of the provider. - * Not strictly required, but helps for debugging. - */ - providerName: "Weatherbit", - - // Set the default config properties that is specific to this provider - defaults: { - apiBase: "https://api.weatherbit.io/v2.0", - apiKey: "", - lat: 0, - lon: 0 - }, - - fetchedLocation () { - return this.fetchedLocationName || ""; - }, - - fetchCurrentWeather () { - this.fetchData(this.getUrl()) - .then((data) => { - if (!data || !data.data[0] || typeof data.data[0].temp === "undefined") { - // No usable data? - return; - } + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + } + + setCallbacks (onDataCallback, onErrorCallback) { + this.onDataCallback = onDataCallback; + this.onErrorCallback = onErrorCallback; + } + + async initialize () { + if (!this.config.apiKey || this.config.apiKey === "YOUR_API_KEY_HERE") { + Log.error("[weatherbit] No API key configured"); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Weatherbit API key required. Get one at https://www.weatherbit.io/", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + return; + } + + this.initializeFetcher(); + } + + initializeFetcher () { + const url = this.getUrl(); + + this.fetcher = new HTTPFetcher(url, { + reloadInterval: this.config.updateInterval, + headers: { + Accept: "application/json" + }, + logContext: "weatherprovider.weatherbit" + }); - const currentWeather = this.generateWeatherDayFromCurrentWeather(data); - this.setCurrentWeather(currentWeather); - }) - .catch(function (request) { - Log.error("[weatherprovider.weatherbit] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - fetchWeatherForecast () { - this.fetchData(this.getUrl()) - .then((data) => { - if (!data || !data.data) { - // No usable data? - return; + this.fetcher.on("response", async (response) => { + try { + const data = await response.json(); + this.handleResponse(data); + } catch (error) { + Log.error("[weatherbit] Parse error:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Failed to parse API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); } + } + }); - const forecast = this.generateWeatherObjectsFromForecast(data.data); - this.setWeatherForecast(forecast); + this.fetcher.on("error", (errorInfo) => { + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); + } + }); + } - this.fetchedLocationName = `${data.city_name}, ${data.state_code}`; - }) - .catch(function (request) { - Log.error("[weatherprovider.weatherbit] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, + getUrl () { + const endpoint = this.getWeatherEndpoint(); + return `${this.config.apiBase}${endpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=M&key=${this.config.apiKey}`; + } - /** - * Overrides method for setting config to check if endpoint is correct for hourly - * @param {object} config The configuration object - */ - setConfig (config) { - this.config = config; - if (!this.config.weatherEndpoint) { - switch (this.config.type) { - case "hourly": - this.config.weatherEndpoint = "/forecast/hourly"; - break; - case "daily": - case "forecast": - this.config.weatherEndpoint = "/forecast/daily"; - break; - case "current": - this.config.weatherEndpoint = "/current"; - break; - default: - Log.error("[weatherprovider.weatherbit] weatherEndpoint not configured and could not resolve it based on type"); + getWeatherEndpoint () { + switch (this.config.type) { + case "hourly": + return "/forecast/hourly"; + case "daily": + case "forecast": + return "/forecast/daily"; + case "current": + default: + return "/current"; + } + } + + handleResponse (data) { + if (!data || !data.data || data.data.length === 0) { + Log.error("[weatherbit] No usable data received"); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "No usable data in API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); } + return; } - }, - // Create a URL from the config and base URL. - getUrl () { - return `${this.config.apiBase}${this.config.weatherEndpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=M&key=${this.config.apiKey}`; - }, + let weatherData = null; + + switch (this.config.type) { + case "current": + weatherData = this.generateCurrentWeather(data); + break; + case "forecast": + case "daily": + weatherData = this.generateForecast(data); + break; + case "hourly": + weatherData = this.generateHourly(data); + break; + default: + Log.error(`[weatherbit] Unknown weather type: ${this.config.type}`); + break; + } - // Implement WeatherDay generator. - generateWeatherDayFromCurrentWeather (currentWeatherData) { - //Calculate TZ Offset and invert to convert Sunrise/Sunset times to Local - const d = new Date(); - let tzOffset = d.getTimezoneOffset(); - tzOffset = tzOffset * -1; + if (weatherData && this.onDataCallback) { + this.onDataCallback(weatherData); + } + } - const currentWeather = new WeatherObject(); + generateCurrentWeather (data) { + if (!data.data[0] || typeof data.data[0].temp === "undefined") { + return null; + } - currentWeather.date = moment.unix(currentWeatherData.data[0].ts); - currentWeather.humidity = parseFloat(currentWeatherData.data[0].rh); - currentWeather.temperature = parseFloat(currentWeatherData.data[0].temp); - currentWeather.windSpeed = parseFloat(currentWeatherData.data[0].wind_spd); - currentWeather.windFromDirection = currentWeatherData.data[0].wind_dir; - currentWeather.weatherType = this.convertWeatherType(currentWeatherData.data[0].weather.icon); - currentWeather.sunrise = moment(currentWeatherData.data[0].sunrise, "HH:mm").add(tzOffset, "m"); - currentWeather.sunset = moment(currentWeatherData.data[0].sunset, "HH:mm").add(tzOffset, "m"); + const current = data.data[0]; - this.fetchedLocationName = `${currentWeatherData.data[0].city_name}, ${currentWeatherData.data[0].state_code}`; + const weather = { + date: new Date(current.ts * 1000), + temperature: parseFloat(current.temp), + humidity: parseFloat(current.rh), + windSpeed: parseFloat(current.wind_spd), + windFromDirection: current.wind_dir || null, + weatherType: this.convertWeatherType(current.weather.icon), + sunrise: null, + sunset: null + }; - return currentWeather; - }, + // Parse sunrise/sunset from HH:mm format (already in local time) + if (current.sunrise) { + const [hours, minutes] = current.sunrise.split(":"); + const sunrise = new Date(current.ts * 1000); + sunrise.setHours(parseInt(hours), parseInt(minutes), 0, 0); + weather.sunrise = sunrise; + } - generateWeatherObjectsFromForecast (forecasts) { - const days = []; + if (current.sunset) { + const [hours, minutes] = current.sunset.split(":"); + const sunset = new Date(current.ts * 1000); + sunset.setHours(parseInt(hours), parseInt(minutes), 0, 0); + weather.sunset = sunset; + } - for (const forecast of forecasts) { - const weather = new WeatherObject(); + return weather; + } - weather.date = moment(forecast.datetime, "YYYY-MM-DD"); - weather.minTemperature = forecast.min_temp; - weather.maxTemperature = forecast.max_temp; - weather.precipitationAmount = forecast.precip; - weather.precipitationProbability = forecast.pop; - weather.weatherType = this.convertWeatherType(forecast.weather.icon); + generateForecast (data) { + const days = []; - days.push(weather); + for (const forecast of data.data) { + days.push({ + date: new Date(forecast.datetime), + minTemperature: forecast.min_temp !== undefined ? parseFloat(forecast.min_temp) : null, + maxTemperature: forecast.max_temp !== undefined ? parseFloat(forecast.max_temp) : null, + precipitationAmount: forecast.precip !== undefined ? parseFloat(forecast.precip) : 0, + precipitationProbability: forecast.pop !== undefined ? parseFloat(forecast.pop) : null, + weatherType: this.convertWeatherType(forecast.weather.icon) + }); } return days; - }, + } + + generateHourly (data) { + const hours = []; - // Map icons from Dark Sky to our icons. + for (const forecast of data.data) { + hours.push({ + date: new Date(forecast.timestamp_local), + temperature: forecast.temp !== undefined ? parseFloat(forecast.temp) : null, + precipitationAmount: forecast.precip !== undefined ? parseFloat(forecast.precip) : 0, + precipitationProbability: forecast.pop !== undefined ? parseFloat(forecast.pop) : null, + windSpeed: forecast.wind_spd !== undefined ? parseFloat(forecast.wind_spd) : null, + windFromDirection: forecast.wind_dir || null, + weatherType: this.convertWeatherType(forecast.weather.icon) + }); + } + + return hours; + } + + /** + * Convert Weatherbit icon codes to weathericons.css icons + * See: https://www.weatherbit.io/api/codes + * @param {string} weatherType - Weatherbit icon code + * @returns {string|null} Weathericons.css icon name or null + */ convertWeatherType (weatherType) { const weatherTypes = { t01d: "day-thunderstorm", @@ -200,6 +273,20 @@ WeatherProvider.register("weatherbit", { u00n: "rain-mix" }; - return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + return weatherTypes[weatherType] || null; + } + + start () { + if (this.fetcher) { + this.fetcher.startPeriodicFetch(); + } + } + + stop () { + if (this.fetcher) { + this.fetcher.clearTimer(); + } } -}); +} + +module.exports = WeatherbitProvider; diff --git a/defaultmodules/weather/providers/weatherflow.js b/defaultmodules/weather/providers/weatherflow.js index 8e9fad6fd9..d1d67394f9 100644 --- a/defaultmodules/weather/providers/weatherflow.js +++ b/defaultmodules/weather/providers/weatherflow.js @@ -1,122 +1,254 @@ -/* global WeatherProvider, WeatherObject, WeatherUtils */ +const Log = require("logger"); +const { convertKmhToMs } = require("../provider-utils"); +const HTTPFetcher = require("#http_fetcher"); -/* - * This class is a provider for Weatherflow. - * Note that the Weatherflow API does not provide snowfall. +/** + * WeatherFlow weather provider + * This class is a provider for WeatherFlow personal weather stations. + * Note that the WeatherFlow API does not provide snowfall. */ -WeatherProvider.register("weatherflow", { +class WeatherFlowProvider { + /** + * @param {object} config - Provider configuration + */ + constructor (config) { + this.config = config; + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + } - /* - * Set the name of the provider. - * Not strictly required, but helps for debugging + /** + * Set the callbacks for data and errors + * @param {(data: object) => void} onDataCallback - Called when new data is available + * @param {(error: object) => void} onErrorCallback - Called when an error occurs */ - providerName: "WeatherFlow", - - // Set the default config properties that is specific to this provider - defaults: { - apiBase: "https://swd.weatherflow.com/swd/rest/", - token: "", - stationid: "" - }, - - fetchCurrentWeather () { - this.fetchData(this.getUrl()) - .then((data) => { - const currentWeather = new WeatherObject(); - currentWeather.date = moment(); - - // Other available values: air_density, brightness, delta_t, dew_point, - // pressure_trend (i.e. rising/falling), sea_level_pressure, wind gust, and more. - - currentWeather.humidity = data.current_conditions.relative_humidity; - currentWeather.temperature = data.current_conditions.air_temperature; - currentWeather.feelsLikeTemp = data.current_conditions.feels_like; - currentWeather.windSpeed = WeatherUtils.convertWindToMs(data.current_conditions.wind_avg); - currentWeather.windFromDirection = data.current_conditions.wind_direction; - currentWeather.weatherType = this.convertWeatherType(data.current_conditions.icon); - currentWeather.uv_index = data.current_conditions.uv; - currentWeather.sunrise = moment.unix(data.forecast.daily[0].sunrise); - currentWeather.sunset = moment.unix(data.forecast.daily[0].sunset); - this.setCurrentWeather(currentWeather); - this.fetchedLocationName = data.location_name; - }) - .catch(function (request) { - Log.error("[weatherprovider.weatherflow] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - fetchWeatherForecast () { - this.fetchData(this.getUrl()) - .then((data) => { - const days = []; - - for (const forecast of data.forecast.daily) { - const weather = new WeatherObject(); - - weather.date = moment.unix(forecast.day_start_local); - weather.minTemperature = forecast.air_temp_low; - weather.maxTemperature = forecast.air_temp_high; - weather.precipitationProbability = forecast.precip_probability; - weather.weatherType = this.convertWeatherType(forecast.icon); - - // Must manually build UV and Precipitation from hourly - weather.precipitationAmount = 0.0; // This will sum up rain and snow - weather.precipitationUnits = "mm"; - weather.uv_index = 0; - - for (const hour of data.forecast.hourly) { - const hour_time = moment.unix(hour.time); - if (hour_time.day() === weather.date.day()) { // Iterate though until day is reached - // Get data from today - weather.uv_index = Math.max(weather.uv_index, hour.uv); - weather.precipitationAmount += (hour.precip ?? 0); - } else if (hour_time.diff(weather.date) >= 86400) { - break; // No more data to be found - } - } - days.push(weather); - } - this.setWeatherForecast(days); - this.fetchedLocationName = data.location_name; - }) - .catch(function (request) { - Log.error("[weatherprovider.weatherflow] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - fetchWeatherHourly () { - this.fetchData(this.getUrl()) - .then((data) => { - const hours = []; - for (const hour of data.forecast.hourly) { - const weather = new WeatherObject(); - - weather.date = moment.unix(hour.time); - weather.temperature = hour.air_temperature; - weather.feelsLikeTemp = hour.feels_like; - weather.humidity = hour.relative_humidity; - weather.windSpeed = hour.wind_avg; - weather.windFromDirection = hour.wind_direction; - weather.weatherType = this.convertWeatherType(hour.icon); - weather.precipitationProbability = hour.precip_probability; - weather.precipitationAmount = hour.precip; // NOTE: precipitation type is available - weather.precipitationUnits = "mm"; // Hardcoded via request, TODO: Add conversion - weather.uv_index = hour.uv; - - hours.push(weather); - if (hours.length >= 48) break; // 10 days of hours are available, best to trim down. + setCallbacks (onDataCallback, onErrorCallback) { + this.onDataCallback = onDataCallback; + this.onErrorCallback = onErrorCallback; + } + + /** + * Initialize the provider + */ + async initialize () { + if (!this.config.token || this.config.token === "YOUR_API_TOKEN_HERE") { + Log.error("[weatherflow] No API token configured. Get one at https://tempestwx.com/"); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "WeatherFlow API token required. Get one at https://tempestwx.com/", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + return; + } + + if (!this.config.stationid) { + Log.error("[weatherflow] No station ID configured"); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "WeatherFlow station ID required", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + return; + } + + this.initializeFetcher(); + } + + /** + * Initialize the HTTP fetcher + */ + initializeFetcher () { + const url = this.getUrl(); + + this.fetcher = new HTTPFetcher(url, { + reloadInterval: this.config.updateInterval, + headers: { + "Cache-Control": "no-cache", + Accept: "application/json" + }, + logContext: "weatherprovider.weatherflow" + }); + + this.fetcher.on("response", async (response) => { + try { + const data = await response.json(); + const processed = this.processData(data); + this.onDataCallback(processed); + } catch (error) { + Log.error("[weatherflow] Failed to parse JSON:", error); + } + }); + + this.fetcher.on("error", (errorInfo) => { + // HTTPFetcher already logged the error with logContext + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); + } + }); + } + + /** + * Generate the URL for API requests + * @returns {string} The API URL + */ + getUrl () { + const base = this.config.apiBase || "https://swd.weatherflow.com/swd/rest/"; + return `${base}better_forecast?station_id=${this.config.stationid}&units_temp=c&units_wind=kph&units_pressure=mb&units_precip=mm&units_distance=km&token=${this.config.token}`; + } + + /** + * Process the raw API data + * @param {object} data - Raw API response + * @returns {object} Processed weather data + */ + processData (data) { + try { + let weatherData; + if (this.config.type === "current") { + weatherData = this.generateCurrentWeather(data); + } else if (this.config.type === "hourly") { + weatherData = this.generateHourly(data); + } else { + weatherData = this.generateForecast(data); + } + + return weatherData; + } catch (error) { + Log.error("[weatherflow] Data processing error:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Failed to process weather data", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + return null; + } + } + + /** + * Generate current weather data + * @param {object} data - API response data + * @returns {object} Current weather object + */ + generateCurrentWeather (data) { + if (!data || !data.current_conditions || !data.forecast || !Array.isArray(data.forecast.daily) || data.forecast.daily.length === 0) { + Log.error("[weatherflow] Invalid current weather data structure"); + return null; + } + + const current = data.current_conditions; + const daily = data.forecast.daily[0]; + + const weather = { + date: new Date(), + humidity: current.relative_humidity || null, + temperature: current.air_temperature || null, + feelsLikeTemp: current.feels_like || null, + windSpeed: current.wind_avg != null ? convertKmhToMs(current.wind_avg) : null, + windFromDirection: current.wind_direction || null, + weatherType: this.convertWeatherType(current.icon), + uvIndex: current.uv || null, + sunrise: daily.sunrise ? new Date(daily.sunrise * 1000) : null, + sunset: daily.sunset ? new Date(daily.sunset * 1000) : null + }; + + return weather; + } + + /** + * Generate forecast data + * @param {object} data - API response data + * @returns {Array} Array of forecast objects + */ + generateForecast (data) { + if (!data || !data.forecast || !Array.isArray(data.forecast.daily) || !Array.isArray(data.forecast.hourly)) { + Log.error("[weatherflow] Invalid forecast data structure"); + return []; + } + + const days = []; + + for (const forecast of data.forecast.daily) { + const weather = { + date: new Date(forecast.day_start_local * 1000), + minTemperature: forecast.air_temp_low || null, + maxTemperature: forecast.air_temp_high || null, + precipitationProbability: forecast.precip_probability || null, + weatherType: this.convertWeatherType(forecast.icon), + precipitationAmount: 0.0, + precipitationUnits: "mm", + uvIndex: 0 + }; + + // Build UV and precipitation from hourly data + for (const hour of data.forecast.hourly) { + const hourDate = new Date(hour.time * 1000); + const forecastDate = new Date(forecast.day_start_local * 1000); + + // Compare year, month, and day to ensure correct matching across month boundaries + if (hourDate.getFullYear() === forecastDate.getFullYear() + && hourDate.getMonth() === forecastDate.getMonth() + && hourDate.getDate() === forecastDate.getDate()) { + weather.uvIndex = Math.max(weather.uvIndex, hour.uv || 0); + weather.precipitationAmount += hour.precip || 0; + } else if (hourDate > forecastDate) { + // Check if we've moved to the next day + const diffMs = hourDate - forecastDate; + if (diffMs >= 86400000) break; // 24 hours in ms } - this.setWeatherHourly(hours); - this.fetchedLocationName = data.location_name; - }) - .catch(function (request) { - Log.error("[weatherprovider.weatherflow] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, + } + + days.push(weather); + } + + return days; + } + + /** + * Generate hourly forecast data + * @param {object} data - API response data + * @returns {Array} Array of hourly forecast objects + */ + generateHourly (data) { + if (!data || !data.forecast || !Array.isArray(data.forecast.hourly)) { + Log.error("[weatherflow] Invalid hourly data structure"); + return []; + } + + const hours = []; + for (const hour of data.forecast.hourly) { + const weather = { + date: new Date(hour.time * 1000), + temperature: hour.air_temperature || null, + feelsLikeTemp: hour.feels_like || null, + humidity: hour.relative_humidity || null, + windSpeed: hour.wind_avg != null ? convertKmhToMs(hour.wind_avg) : null, + windFromDirection: hour.wind_direction || null, + weatherType: this.convertWeatherType(hour.icon), + precipitationProbability: hour.precip_probability || null, + precipitationAmount: hour.precip || 0, + precipitationUnits: "mm", + uvIndex: hour.uv || null + }; + + hours.push(weather); + + // WeatherFlow provides 10 days of hourly data, trim to 48 hours + if (hours.length >= 48) break; + } + + return hours; + } + + /** + * Convert weather icon type + * @param {string} weatherType - WeatherFlow icon code + * @returns {string} Weather icon CSS class + */ convertWeatherType (weatherType) { const weatherTypes = { "clear-day": "day-sunny", @@ -140,11 +272,26 @@ WeatherProvider.register("weatherflow", { windy: "strong-wind" }; - return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; - }, + return weatherTypes[weatherType] || null; + } - // Create a URL from the config and base URL. - getUrl () { - return `${this.config.apiBase}better_forecast?station_id=${this.config.stationid}&units_temp=c&units_wind=kph&units_pressure=mb&units_precip=mm&units_distance=km&token=${this.config.token}`; + /** + * Start fetching data + */ + start () { + if (this.fetcher) { + this.fetcher.startPeriodicFetch(); + } + } + + /** + * Stop fetching data + */ + stop () { + if (this.fetcher) { + this.fetcher.clearTimer(); + } } -}); +} + +module.exports = WeatherFlowProvider; diff --git a/defaultmodules/weather/providers/weathergov.js b/defaultmodules/weather/providers/weathergov.js index 7dae337b22..5fff8f61a0 100644 --- a/defaultmodules/weather/providers/weathergov.js +++ b/defaultmodules/weather/providers/weathergov.js @@ -1,369 +1,426 @@ -/* global WeatherProvider, WeatherObject, WeatherUtils */ +const Log = require("logger"); +const { getSunTimes, isDayTime, getDateString, convertKmhToMs } = require("../provider-utils"); +const HTTPFetcher = require("#http_fetcher"); -/* - * Provider: weather.gov +/** + * Server-side weather provider for Weather.gov (US National Weather Service) + * Note: Only works for US locations, no API key required * https://weather-gov.github.io/api/general-faqs - * - * This class is a provider for weather.gov. - * Note that this is only for US locations (lat and lon) and does not require an API key - * Since it is free, there are some items missing - like sunrise, sunset */ +class WeatherGovProvider { + constructor (config) { + this.config = { + apiBase: "https://api.weather.gov/points/", + lat: 0, + lon: 0, + type: "current", + updateInterval: 10 * 60 * 1000, + ...config + }; + + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + this.locationName = null; + this.initRetryCount = 0; + this.initRetryTimer = null; + + // Weather.gov specific URLs (fetched during initialization) + this.forecastURL = null; + this.forecastHourlyURL = null; + this.forecastGridDataURL = null; + this.observationStationsURL = null; + this.stationObsURL = null; + } -WeatherProvider.register("weathergov", { - - /* - * Set the name of the provider. - * This isn't strictly necessary, since it will fallback to the provider identifier - * But for debugging (and future alerts) it would be nice to have the real name. - */ - providerName: "Weather.gov", - - // Set the default config properties that is specific to this provider - defaults: { - apiBase: "https://api.weather.gov/points/", - lat: 0, - lon: 0 - }, - - // Flag all needed URLs availability - configURLs: false, - - //This API has multiple urls involved - forecastURL: "tbd", - forecastHourlyURL: "tbd", - forecastGridDataURL: "tbd", - observationStationsURL: "tbd", - stationObsURL: "tbd", - - // Called to set the config, this config is the same as the weather module's config. - setConfig (config) { - this.config = config; - this.fetchWxGovURLs(this.config); - }, - - // This returns the name of the fetched location or an empty string. - fetchedLocation () { - return this.fetchedLocationName || ""; - }, - - // Overwrite the fetchCurrentWeather method. - fetchCurrentWeather () { - if (!this.configURLs) { - Log.info("[weatherprovider.weathergov] fetchCurrentWeather: fetch wx waiting on config URLs"); - return; + async initialize () { + // Add small random delay to prevent all instances from starting simultaneously + // This reduces parallel DNS lookups which can cause EAI_AGAIN errors + const staggerDelay = Math.random() * 3000; // 0-3 seconds + await new Promise((resolve) => setTimeout(resolve, staggerDelay)); + + try { + await this.#fetchWeatherGovURLs(); + this.#initializeFetcher(); + this.initRetryCount = 0; // Reset on success + } catch (error) { + const errorInfo = this.#categorizeError(error); + Log.error(`[weathergov] Initialization failed: ${errorInfo.message}`); + + // Retry on temporary errors (DNS, timeout, network) + if (errorInfo.isRetryable && this.initRetryCount < 5) { + this.initRetryCount++; + const delay = Math.min(30000 * Math.pow(2, this.initRetryCount - 1), 5 * 60 * 1000); // 30s, 60s, 120s, 240s, 300s max + Log.info(`[weathergov] Will retry initialization in ${Math.round(delay / 1000)}s (attempt ${this.initRetryCount}/5)`); + this.initRetryTimer = setTimeout(() => this.initialize(), delay); + } else if (this.onErrorCallback) { + this.onErrorCallback({ + message: errorInfo.message, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } } - this.fetchData(this.stationObsURL) - .then((data) => { - if (!data || !data.properties) { - // Did not receive usable new data. - return; - } - const currentWeather = this.generateWeatherObjectFromCurrentWeather(data.properties); - this.setCurrentWeather(currentWeather); - }) - .catch(function (request) { - Log.error("[weatherprovider.weathergov] Could not load station obs data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - // Overwrite the fetchWeatherForecast method. - fetchWeatherForecast () { - if (!this.configURLs) { - Log.info("[weatherprovider.weathergov] fetchWeatherForecast: fetch wx waiting on config URLs"); - return; + } + + #categorizeError (error) { + const cause = error.cause || error; + const code = cause.code || ""; + + if (code === "EAI_AGAIN" || code === "ENOTFOUND") { + return { + message: "DNS lookup failed for api.weather.gov - check your internet connection", + isRetryable: true + }; } - this.fetchData(this.forecastURL) - .then((data) => { - if (!data || !data.properties || !data.properties.periods || !data.properties.periods.length) { - // Did not receive usable new data. - return; - } - const forecast = this.generateWeatherObjectsFromForecast(data.properties.periods); - this.setWeatherForecast(forecast); - }) - .catch(function (request) { - Log.error("[weatherprovider.weathergov] Could not load forecast hourly data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - // Overwrite the fetchWeatherHourly method. - fetchWeatherHourly () { - if (!this.configURLs) { - Log.info("[weatherprovider.weathergov] fetchWeatherHourly: fetch wx waiting on config URLs"); - return; + if (code === "ETIMEDOUT" || code === "ECONNREFUSED" || code === "ECONNRESET") { + return { + message: `Network error: ${code} - api.weather.gov may be temporarily unavailable`, + isRetryable: true + }; } - this.fetchData(this.forecastHourlyURL) - .then((data) => { - if (!data) { - - /* - * Did not receive usable new data. - * Maybe this needs a better check? - */ - return; - } - const hourly = this.generateWeatherObjectsFromHourly(data.properties.periods); - this.setWeatherHourly(hourly); - }) - .catch(function (request) { - Log.error("[weatherprovider.weathergov] Could not load data ... ", request); - }) - .finally(() => this.updateAvailable()); - }, - - /** Weather.gov Specific Methods - These are not part of the default provider methods */ - - /* - * Get specific URLs - */ - fetchWxGovURLs (config) { - this.fetchData(`${config.apiBase}/${config.lat},${config.lon}`) - .then((data) => { - if (!data || !data.properties) { - // points URL did not respond with usable data. - return; - } - this.fetchedLocationName = `${data.properties.relativeLocation.properties.city}, ${data.properties.relativeLocation.properties.state}`; - Log.log(`[weatherprovider.weathergov] Forecast location is ${this.fetchedLocationName}`); - this.forecastURL = `${data.properties.forecast}?units=si`; - this.forecastHourlyURL = `${data.properties.forecastHourly}?units=si`; - this.forecastGridDataURL = data.properties.forecastGridData; - this.observationStationsURL = data.properties.observationStations; - // with this URL, we chain another promise for the station obs URL - return this.fetchData(data.properties.observationStations); - }) - .then((obsData) => { - if (!obsData || !obsData.features) { - // obs station URL did not respond with usable data. - return; + if (error.name === "AbortError") { + return { + message: "Request timeout - api.weather.gov is responding slowly", + isRetryable: true + }; + } + + return { + message: error.message || "Unknown error", + isRetryable: false + }; + } + + setCallbacks (onData, onError) { + this.onDataCallback = onData; + this.onErrorCallback = onError; + } + + start () { + if (this.fetcher) { + this.fetcher.startPeriodicFetch(); + } + } + + stop () { + if (this.fetcher) { + this.fetcher.clearTimer(); + } + if (this.initRetryTimer) { + clearTimeout(this.initRetryTimer); + this.initRetryTimer = null; + } + } + + async #fetchWeatherGovURLs () { + // Step 1: Get grid point data + const pointsUrl = `${this.config.apiBase}${this.config.lat},${this.config.lon}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 120000); // 120 second timeout - DNS can be slow + + try { + const pointsResponse = await fetch(pointsUrl, { + signal: controller.signal, + headers: { + "User-Agent": "MagicMirror", + Accept: "application/geo+json" } - this.stationObsURL = `${obsData.features[0].id}/observations/latest`; - }) - .catch((err) => { - Log.error("[weatherprovider.weathergov] fetchWxGovURLs error: ", err); - }) - .finally(() => { - // excellent, let's fetch some actual wx data - this.configURLs = true; - - // handle 'forecast' config, fall back to 'current' - if (config.type === "forecast") { - this.fetchWeatherForecast(); - } else if (config.type === "hourly") { - this.fetchWeatherHourly(); - } else { - this.fetchCurrentWeather(); + }); + + if (!pointsResponse.ok) { + throw new Error(`Failed to fetch grid point: HTTP ${pointsResponse.status}`); + } + + const pointsData = await pointsResponse.json(); + + if (!pointsData || !pointsData.properties) { + throw new Error("Invalid grid point data"); + } + + // Extract location name + const relLoc = pointsData.properties.relativeLocation?.properties; + if (relLoc) { + this.locationName = `${relLoc.city}, ${relLoc.state}`; + } + + // Store forecast URLs + this.forecastURL = `${pointsData.properties.forecast}?units=si`; + this.forecastHourlyURL = `${pointsData.properties.forecastHourly}?units=si`; + this.forecastGridDataURL = pointsData.properties.forecastGridData; + this.observationStationsURL = pointsData.properties.observationStations; + + // Step 2: Get observation station URL + const stationsResponse = await fetch(this.observationStationsURL, { + signal: controller.signal, + headers: { + "User-Agent": "MagicMirror", + Accept: "application/geo+json" } }); - }, - - /* - * Generate a WeatherObject based on hourlyWeatherInformation - * Weather.gov API uses specific units; API does not include choice of units - * ... object needs data in units based on config! - */ - generateWeatherObjectsFromHourly (forecasts) { - const days = []; - // variable for date - let weather = new WeatherObject(); - for (const forecast of forecasts) { - weather.date = moment(forecast.startTime.slice(0, 19)); - if (forecast.windSpeed.search(" ") < 0) { - weather.windSpeed = forecast.windSpeed; - } else { - weather.windSpeed = forecast.windSpeed.slice(0, forecast.windSpeed.search(" ")); + if (!stationsResponse.ok) { + throw new Error(`Failed to fetch observation stations: HTTP ${stationsResponse.status}`); } - weather.windSpeed = WeatherUtils.convertWindToMs(weather.windSpeed); - weather.windFromDirection = forecast.windDirection; - weather.temperature = forecast.temperature; - //assign probability of precipitation - if (forecast.probabilityOfPrecipitation.value === null) { - weather.precipitationProbability = 0; - } else { - weather.precipitationProbability = forecast.probabilityOfPrecipitation.value; + + const stationsData = await stationsResponse.json(); + + if (!stationsData || !stationsData.features || stationsData.features.length === 0) { + throw new Error("No observation stations found"); } - // use the forecast isDayTime attribute to help build the weatherType label - weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); - days.push(weather); + this.stationObsURL = `${stationsData.features[0].id}/observations/latest`; + + Log.log(`[weathergov] Initialized for ${this.locationName}`); + } finally { + clearTimeout(timeoutId); + } + } - weather = new WeatherObject(); + #initializeFetcher () { + let url; + + switch (this.config.type) { + case "current": + url = this.stationObsURL; + break; + case "forecast": + case "daily": + url = this.forecastURL; + break; + case "hourly": + url = this.forecastHourlyURL; + break; + default: + url = this.stationObsURL; } - // push weather information to days array - days.push(weather); - return days; - }, - - /* - * Generate a WeatherObject based on currentWeatherInformation - * Weather.gov API uses specific units; API does not include choice of units - * ... object needs data in units based on config! - */ - generateWeatherObjectFromCurrentWeather (currentWeatherData) { - const currentWeather = new WeatherObject(); - - currentWeather.date = moment(currentWeatherData.timestamp); - currentWeather.temperature = currentWeatherData.temperature.value; - currentWeather.windSpeed = WeatherUtils.convertWindToMs(currentWeatherData.windSpeed.value); - currentWeather.windFromDirection = currentWeatherData.windDirection.value; - currentWeather.minTemperature = currentWeatherData.minTemperatureLast24Hours.value; - currentWeather.maxTemperature = currentWeatherData.maxTemperatureLast24Hours.value; - currentWeather.humidity = Math.round(currentWeatherData.relativeHumidity.value); - currentWeather.precipitationAmount = currentWeatherData.precipitationLastHour?.value ?? currentWeatherData.precipitationLast3Hours?.value; + this.fetcher = new HTTPFetcher(url, { + reloadInterval: this.config.updateInterval, + timeout: 60000, // 60 seconds - weather.gov can be slow + headers: { + "User-Agent": "MagicMirror", + Accept: "application/geo+json", + "Cache-Control": "no-cache" + }, + logContext: "weatherprovider.weathergov" + }); + + this.fetcher.on("response", async (response) => { + try { + const data = await response.json(); + this.#handleResponse(data); + } catch (error) { + Log.error("[weathergov] Failed to parse JSON:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Failed to parse API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + } + }); + + this.fetcher.on("error", (errorInfo) => { + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); + } + }); + } + + #handleResponse (data) { + try { + let weatherData; + + switch (this.config.type) { + case "current": + if (!data.properties) { + throw new Error("Invalid current weather data"); + } + weatherData = this.#generateWeatherObjectFromCurrentWeather(data.properties); + break; + case "forecast": + case "daily": + if (!data.properties || !data.properties.periods) { + throw new Error("Invalid forecast data"); + } + weatherData = this.#generateWeatherObjectsFromForecast(data.properties.periods); + break; + case "hourly": + if (!data.properties || !data.properties.periods) { + throw new Error("Invalid hourly data"); + } + weatherData = this.#generateWeatherObjectsFromHourly(data.properties.periods); + break; + default: + throw new Error(`Unknown weather type: ${this.config.type}`); + } + + if (this.onDataCallback) { + this.onDataCallback(weatherData); + } + } catch (error) { + Log.error("[weathergov] Error processing weather data:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: error.message, + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } + } + } + + #generateWeatherObjectFromCurrentWeather (currentWeatherData) { + const current = {}; + + current.date = new Date(currentWeatherData.timestamp); + current.temperature = currentWeatherData.temperature.value; + current.windSpeed = currentWeatherData.windSpeed.value; // Observations are already in m/s + current.windFromDirection = currentWeatherData.windDirection.value; + current.minTemperature = currentWeatherData.minTemperatureLast24Hours?.value; + current.maxTemperature = currentWeatherData.maxTemperatureLast24Hours?.value; + current.humidity = Math.round(currentWeatherData.relativeHumidity.value); + current.precipitationAmount = currentWeatherData.precipitationLastHour?.value ?? currentWeatherData.precipitationLast3Hours?.value; + + // Feels like temperature if (currentWeatherData.heatIndex.value !== null) { - currentWeather.feelsLikeTemp = currentWeatherData.heatIndex.value; + current.feelsLikeTemp = currentWeatherData.heatIndex.value; } else if (currentWeatherData.windChill.value !== null) { - currentWeather.feelsLikeTemp = currentWeatherData.windChill.value; + current.feelsLikeTemp = currentWeatherData.windChill.value; } else { - currentWeather.feelsLikeTemp = currentWeatherData.temperature.value; + current.feelsLikeTemp = currentWeatherData.temperature.value; } - // determine the sunrise/sunset times - not supplied in weather.gov data - currentWeather.updateSunTime(this.config.lat, this.config.lon); - - // update weatherType - currentWeather.weatherType = this.convertWeatherType(currentWeatherData.textDescription, currentWeather.isDayTime()); - - return currentWeather; - }, - - /* - * Generate WeatherObjects based on forecast information - */ - generateWeatherObjectsFromForecast (forecasts) { - return this.fetchForecastDaily(forecasts); - }, - - /* - * fetch forecast information for daily forecast. - */ - fetchForecastDaily (forecasts) { - // initial variable declaration + + // Calculate sunrise/sunset (not provided by weather.gov) + const { sunrise, sunset } = getSunTimes(current.date, this.config.lat, this.config.lon); + current.sunrise = sunrise; + current.sunset = sunset; + + // Determine if daytime + const isDay = isDayTime(current.date, current.sunrise, current.sunset); + current.weatherType = this.#convertWeatherType(currentWeatherData.textDescription, isDay); + + return current; + } + + #generateWeatherObjectsFromForecast (forecasts) { const days = []; - // variables for temperature range and rain let minTemp = []; let maxTemp = []; - // variable for date let date = ""; - let weather = new WeatherObject(); + let weather = {}; for (const forecast of forecasts) { - if (date !== moment(forecast.startTime).format("YYYY-MM-DD")) { - // calculate minimum/maximum temperature, specify rain amount - weather.minTemperature = Math.min.apply(null, minTemp); - weather.maxTemperature = Math.max.apply(null, maxTemp); - - // push weather information to days array - days.push(weather); - // create new weather-object - weather = new WeatherObject(); + const forecastDate = new Date(forecast.startTime); + const dateStr = getDateString(forecastDate); + + if (date !== dateStr) { + // New day + if (date !== "") { + weather.minTemperature = Math.min(...minTemp); + weather.maxTemperature = Math.max(...maxTemp); + days.push(weather); + } + weather = {}; minTemp = []; maxTemp = []; - //assign probability of precipitation - if (forecast.probabilityOfPrecipitation.value === null) { - weather.precipitationProbability = 0; - } else { - weather.precipitationProbability = forecast.probabilityOfPrecipitation.value; - } - - // set new date - date = moment(forecast.startTime).format("YYYY-MM-DD"); - - // specify date - weather.date = moment(forecast.startTime); + date = dateStr; - // use the forecast isDayTime attribute to help build the weatherType label - weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); + weather.date = forecastDate; + weather.precipitationProbability = forecast.probabilityOfPrecipitation?.value ?? 0; + weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime); } - if (moment(forecast.startTime).format("H") >= 8 && moment(forecast.startTime).format("H") <= 17) { - weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); + // Update weather type for daytime hours (8am-5pm) + const hour = forecastDate.getHours(); + if (hour >= 8 && hour <= 17) { + weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime); } - /* - * the same day as before - * add values from forecast to corresponding variables - */ minTemp.push(forecast.temperature); maxTemp.push(forecast.temperature); } - /* - * last day - * calculate minimum/maximum temperature - */ - weather.minTemperature = Math.min.apply(null, minTemp); - weather.maxTemperature = Math.max.apply(null, maxTemp); + // Last day + if (date !== "") { + weather.minTemperature = Math.min(...minTemp); + weather.maxTemperature = Math.max(...maxTemp); + days.push(weather); + } - // push weather information to days array - days.push(weather); - return days.slice(1); - }, + return days.slice(1); // Skip first incomplete day + } - /* - * Convert the icons to a more usable name. - */ - convertWeatherType (weatherType, isDaytime) { + #generateWeatherObjectsFromHourly (forecasts) { + const hours = []; - /* - * https://w1.weather.gov/xml/current_obs/weather.php - * There are way too many types to create, so lets just look for certain strings - */ + for (const forecast of forecasts) { + const weather = {}; - if (weatherType.includes("Cloudy") || weatherType.includes("Partly")) { - if (isDaytime) { - return "day-cloudy"; - } + weather.date = new Date(forecast.startTime); - return "night-cloudy"; - } else if (weatherType.includes("Overcast")) { - if (isDaytime) { - return "cloudy"; + // Parse wind speed + const windSpeedStr = forecast.windSpeed; + let windSpeed = windSpeedStr; + if (windSpeedStr.includes(" ")) { + windSpeed = windSpeedStr.split(" ")[0]; } + weather.windSpeed = convertKmhToMs(parseFloat(windSpeed)); + weather.windFromDirection = this.#convertWindDirection(forecast.windDirection); + weather.temperature = forecast.temperature; + weather.precipitationProbability = forecast.probabilityOfPrecipitation?.value ?? 0; + weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime); + + hours.push(weather); + } + + return hours; + } - return "night-cloudy"; + #convertWindDirection (direction) { + const directions = { + N: 0, + NNE: 22.5, + NE: 45, + ENE: 67.5, + E: 90, + ESE: 112.5, + SE: 135, + SSE: 157.5, + S: 180, + SSW: 202.5, + SW: 225, + WSW: 247.5, + W: 270, + WNW: 292.5, + NW: 315, + NNW: 337.5 + }; + return directions[direction] ?? null; + } + + #convertWeatherType (weatherType, isDaytime) { + // https://w1.weather.gov/xml/current_obs/weather.php + + if (weatherType.includes("Cloudy") || weatherType.includes("Partly")) { + return isDaytime ? "day-cloudy" : "night-cloudy"; + } else if (weatherType.includes("Overcast")) { + return isDaytime ? "cloudy" : "night-cloudy"; } else if (weatherType.includes("Freezing") || weatherType.includes("Ice")) { return "rain-mix"; } else if (weatherType.includes("Snow")) { - if (isDaytime) { - return "snow"; - } - - return "night-snow"; + return isDaytime ? "snow" : "night-snow"; } else if (weatherType.includes("Thunderstorm")) { - if (isDaytime) { - return "thunderstorm"; - } - - return "night-thunderstorm"; + return isDaytime ? "thunderstorm" : "night-thunderstorm"; } else if (weatherType.includes("Showers")) { - if (isDaytime) { - return "showers"; - } - - return "night-showers"; + return isDaytime ? "showers" : "night-showers"; } else if (weatherType.includes("Rain") || weatherType.includes("Drizzle")) { - if (isDaytime) { - return "rain"; - } - - return "night-rain"; + return isDaytime ? "rain" : "night-rain"; } else if (weatherType.includes("Breezy") || weatherType.includes("Windy")) { - if (isDaytime) { - return "cloudy-windy"; - } - - return "night-alt-cloudy-windy"; + return isDaytime ? "cloudy-windy" : "night-alt-cloudy-windy"; } else if (weatherType.includes("Fair") || weatherType.includes("Clear") || weatherType.includes("Few") || weatherType.includes("Sunny")) { - if (isDaytime) { - return "day-sunny"; - } - - return "night-clear"; + return isDaytime ? "day-sunny" : "night-clear"; } else if (weatherType.includes("Dust") || weatherType.includes("Sand")) { return "dust"; } else if (weatherType.includes("Fog")) { @@ -376,4 +433,6 @@ WeatherProvider.register("weathergov", { return null; } -}); +} + +module.exports = WeatherGovProvider; diff --git a/defaultmodules/weather/providers/yr.js b/defaultmodules/weather/providers/yr.js index d5a6cb6d5c..3a4b4b0697 100644 --- a/defaultmodules/weather/providers/yr.js +++ b/defaultmodules/weather/providers/yr.js @@ -1,623 +1,467 @@ -/* global WeatherProvider, WeatherObject */ +const Log = require("logger"); +const { formatTimezoneOffset, getDateString, validateCoordinates } = require("../provider-utils"); +const HTTPFetcher = require("#http_fetcher"); -/* - * This class is a provider for Yr.no, a norwegian weather service. +/** + * Server-side weather provider for Yr.no (Norwegian Meteorological Institute) * Terms of service: https://developer.yr.no/doc/TermsOfService/ + * + * Note: Minimum update interval is 10 minutes (600000 ms) per API terms */ -WeatherProvider.register("yr", { - providerName: "Yr", - - // Set the default config properties that is specific to this provider - defaults: { - useCorsProxy: true, - apiBase: "https://api.met.no/weatherapi", - forecastApiVersion: "2.0", - sunriseApiVersion: "3.0", - altitude: 0, - currentForecastHours: 1 //1, 6 or 12 - }, +class YrProvider { + constructor (config) { + this.config = { + apiBase: "https://api.met.no/weatherapi", + forecastApiVersion: "2.0", + sunriseApiVersion: "3.0", + altitude: 0, + lat: 0, + lon: 0, + currentForecastHours: 1, // 1, 6 or 12 + type: "current", + updateInterval: 10 * 60 * 1000, // 10 minutes minimum + ...config + }; - start () { - if (typeof Storage === "undefined") { - //local storage unavailable - Log.error("[weatherprovider.yr] The Yr weather provider requires local storage."); - throw new Error("Local storage not available"); - } + // Enforce 10 minute minimum per API terms if (this.config.updateInterval < 600000) { - Log.warn("[weatherprovider.yr] The Yr weather provider requires a minimum update interval of 10 minutes (600 000 ms). The configuration has been adjusted to meet this requirement."); - this.delegate.config.updateInterval = 600000; + Log.warn("[yr] Minimum update interval is 10 minutes (600000 ms). Adjusting configuration."); + this.config.updateInterval = 600000; } - Log.info(`[weatherprovider.yr] ${this.providerName} started.`); - }, - - fetchCurrentWeather () { - this.getCurrentWeather() - .then((currentWeather) => { - this.setCurrentWeather(currentWeather); - this.updateAvailable(); - }) - .catch((error) => { - Log.error("[weatherprovider.yr] fetchCurrentWeather error:", error); - this.updateAvailable(); - }); - }, - async getCurrentWeather () { - const [weatherData, stellarData] = await Promise.all([this.getWeatherData(), this.getStellarData()]); - if (!stellarData) { - Log.warn("[weatherprovider.yr] No stellar data available."); - } - if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) { - Log.error("[weatherprovider.yr] No weather data available."); - return; - } - const currentTime = moment(); - let forecast = weatherData.properties.timeseries[0]; - let closestTimeInPast = currentTime.diff(moment(forecast.time)); - for (const forecastTime of weatherData.properties.timeseries) { - const comparison = currentTime.diff(moment(forecastTime.time)); - if (0 < comparison && comparison < closestTimeInPast) { - closestTimeInPast = comparison; - forecast = forecastTime; - } - } - const forecastXHours = this.getForecastForXHoursFrom(forecast.data); - forecast.weatherType = this.convertWeatherType(forecastXHours.summary.symbol_code, forecast.time); - forecast.precipitationAmount = forecastXHours.details?.precipitation_amount; - forecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation; - forecast.minTemperature = forecastXHours.details?.air_temperature_min; - forecast.maxTemperature = forecastXHours.details?.air_temperature_max; - return this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units); - }, - - getWeatherData () { - return new Promise((resolve, reject) => { - - /* - * If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes. - * This is to avoid multiple similar calls to the API. - */ - let shouldWait = localStorage.getItem("yrIsFetchingWeatherData"); - if (shouldWait) { - const checkForGo = setInterval(function () { - shouldWait = localStorage.getItem("yrIsFetchingWeatherData"); - }, 100); - setTimeout(function () { - clearInterval(checkForGo); - shouldWait = false; - }, 5000); //Assume other fetch finished but failed to remove lock - const attemptFetchWeather = setInterval(() => { - if (!shouldWait) { - clearInterval(checkForGo); - clearInterval(attemptFetchWeather); - this.getWeatherDataFromYrOrCache(resolve, reject); - } - }, 100); - } else { - this.getWeatherDataFromYrOrCache(resolve, reject); - } - }); - }, - - getWeatherDataFromYrOrCache (resolve, reject) { - localStorage.setItem("yrIsFetchingWeatherData", "true"); - - let weatherData = this.getWeatherDataFromCache(); - if (this.weatherDataIsValid(weatherData)) { - localStorage.removeItem("yrIsFetchingWeatherData"); - Log.debug("[weatherprovider.yr] Weather data found in cache."); - resolve(weatherData); - } else { - this.getWeatherDataFromYr(weatherData?.downloadedAt) - .then((weatherData) => { - Log.debug("[weatherprovider.yr] Got weather data from yr."); - let data; - if (weatherData) { - this.cacheWeatherData(weatherData); - data = weatherData; - } else { - //Undefined if unchanged - data = this.getWeatherDataFromCache(); - } - resolve(data); - }) - .catch((err) => { - Log.error("[weatherprovider.yr] getWeatherDataFromYr error: ", err); - if (weatherData) { - Log.warn("[weatherprovider.yr] Using outdated cached weather data."); - resolve(weatherData); - } else { - reject("Unable to get weather data from Yr."); - } - }) - .finally(() => { - localStorage.removeItem("yrIsFetchingWeatherData"); - }); + this.fetcher = null; + this.onDataCallback = null; + this.onErrorCallback = null; + this.locationName = null; + + // Cache for sunrise/sunset data + this.stellarData = null; + this.stellarDataDate = null; + + // Cache for weather data (If-Modified-Since support) + this.weatherCache = { + data: null, + lastModified: null, + expires: null + }; + } + + async initialize () { + // Yr.no requires max 4 decimal places + validateCoordinates(this.config, 4); + await this.#fetchStellarData(); + this.#initializeFetcher(); + } + + setCallbacks (onData, onError) { + this.onDataCallback = onData; + this.onErrorCallback = onError; + } + + start () { + if (this.fetcher) { + this.fetcher.startPeriodicFetch(); } - }, - - weatherDataIsValid (weatherData) { - return ( - weatherData - && weatherData.timeout - && 0 < moment(weatherData.timeout).diff(moment()) - && (!weatherData.geometry || !weatherData.geometry.coordinates || !weatherData.geometry.coordinates.length < 2 || (weatherData.geometry.coordinates[0] === this.config.lat && weatherData.geometry.coordinates[1] === this.config.lon)) - ); - }, - - getWeatherDataFromCache () { - const weatherData = localStorage.getItem("weatherData"); - if (weatherData) { - return JSON.parse(weatherData); - } else { - return undefined; + } + + stop () { + if (this.fetcher) { + this.fetcher.clearTimer(); } - }, + } + + async #fetchStellarData () { + const today = getDateString(new Date()); - getWeatherDataFromYr (currentDataFetchedAt) { - const requestHeaders = [{ name: "Accept", value: "application/json" }]; - if (currentDataFetchedAt) { - requestHeaders.push({ name: "If-Modified-Since", value: currentDataFetchedAt }); + // Check if we already have today's data + if (this.stellarDataDate === today && this.stellarData) { + return; } - const expectedResponseHeaders = ["expires", "date"]; - - return this.fetchData(this.getForecastUrl(), "json", requestHeaders, expectedResponseHeaders) - .then((data) => { - if (!data || !data.headers) return data; - data.timeout = data.headers.find((header) => header.name === "expires").value; - data.downloadedAt = data.headers.find((header) => header.name === "date").value; - data.headers = undefined; - return data; - }) - .catch((err) => { - Log.error("[weatherprovider.yr] Could not load weather data.", err); - throw new Error(err); + const url = this.#getSunriseUrl(); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(url, { + headers: { + "User-Agent": "MagicMirror", + Accept: "application/json" + }, + signal: controller.signal }); - }, - getConfigOptions () { - if (!this.config.lat) { - Log.error("[weatherprovider.yr] Latitude not provided."); - throw new Error("Latitude not provided."); - } - if (!this.config.lon) { - Log.error("[weatherprovider.yr] Longitude not provided."); - throw new Error("Longitude not provided."); + clearTimeout(timeoutId); + + if (!response.ok) { + Log.warn(`[yr] Could not fetch stellar data: HTTP ${response.status}`); + this.stellarDataDate = today; + } else { + // Parse and store the stellar data + const data = await response.json(); + // Transform single-day response into array format expected by #getStellarInfoForDate + if (data && data.properties) { + this.stellarData = [{ + date: data.when.interval[0], // ISO date string + sunrise: data.properties.sunrise, + sunset: data.properties.sunset + }]; + } + this.stellarDataDate = today; + } + } catch (error) { + Log.warn("[yr] Failed to fetch stellar data:", error); } + } - let lat = this.config.lat.toString(); - let lon = this.config.lon.toString(); - const altitude = this.config.altitude ?? 0; - return { lat, lon, altitude }; - }, + #initializeFetcher () { + const url = this.#getForecastUrl(); - getForecastUrl () { - let { lat, lon, altitude } = this.getConfigOptions(); + const headers = { + "User-Agent": "MagicMirror", + Accept: "application/json" + }; - if (lat.includes(".") && lat.split(".")[1].length > 4) { - Log.warn("[weatherprovider.yr] Latitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length."); - const latParts = lat.split("."); - lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`; - } - if (lon.includes(".") && lon.split(".")[1].length > 4) { - Log.warn("[weatherprovider.yr] Longitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length."); - const lonParts = lon.split("."); - lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`; + // Add If-Modified-Since header if we have cached data + if (this.weatherCache.lastModified) { + headers["If-Modified-Since"] = this.weatherCache.lastModified; } - return `${this.config.apiBase}/locationforecast/${this.config.forecastApiVersion}/complete?&altitude=${altitude}&lat=${lat}&lon=${lon}`; - }, - - cacheWeatherData (weatherData) { - localStorage.setItem("weatherData", JSON.stringify(weatherData)); - }, - - getStellarData () { - - /* - * If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes. - * This is to avoid multiple similar calls to the API. - */ - return new Promise((resolve, reject) => { - let shouldWait = localStorage.getItem("yrIsFetchingStellarData"); - if (shouldWait) { - const checkForGo = setInterval(function () { - shouldWait = localStorage.getItem("yrIsFetchingStellarData"); - }, 100); - setTimeout(function () { - clearInterval(checkForGo); - shouldWait = false; - }, 5000); //Assume other fetch finished but failed to remove lock - const attemptFetchWeather = setInterval(() => { - if (!shouldWait) { - clearInterval(checkForGo); - clearInterval(attemptFetchWeather); - this.getStellarDataFromYrOrCache(resolve, reject); + this.fetcher = new HTTPFetcher(url, { + reloadInterval: this.config.updateInterval, + headers, + logContext: "weatherprovider.yr" + }); + + this.fetcher.on("response", async (response) => { + try { + // Handle 304 Not Modified - use cached data + if (response.status === 304) { + Log.log("[yr] Data not modified, using cache"); + if (this.weatherCache.data) { + this.#handleResponse(this.weatherCache.data, true); } - }, 100); - } else { - this.getStellarDataFromYrOrCache(resolve, reject); + return; + } + + const data = await response.json(); + + // Store cache headers + const lastModified = response.headers.get("Last-Modified"); + const expires = response.headers.get("Expires"); + + if (lastModified) { + this.weatherCache.lastModified = lastModified; + } + if (expires) { + this.weatherCache.expires = expires; + } + this.weatherCache.data = data; + + // Update headers for next request + if (lastModified && this.fetcher) { + this.fetcher.customHeaders["If-Modified-Since"] = lastModified; + } + + this.#handleResponse(data, false); + } catch (error) { + Log.error("[yr] Failed to parse JSON:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: "Failed to parse API response", + translationKey: "MODULE_ERROR_UNSPECIFIED" + }); + } } }); - }, - - getStellarDataFromYrOrCache (resolve, reject) { - localStorage.setItem("yrIsFetchingStellarData", "true"); - - let stellarData = this.getStellarDataFromCache(); - const today = moment().format("YYYY-MM-DD"); - const tomorrow = moment().add(1, "days").format("YYYY-MM-DD"); - if (stellarData && stellarData.today && stellarData.today.date === today && stellarData.tomorrow && stellarData.tomorrow.date === tomorrow) { - Log.debug("[weatherprovider.yr] Stellar data found in cache."); - localStorage.removeItem("yrIsFetchingStellarData"); - resolve(stellarData); - } else if (stellarData && stellarData.tomorrow && stellarData.tomorrow.date === today) { - Log.debug("[weatherprovider.yr] Stellar data for today found in cache, but not for tomorrow."); - stellarData.today = stellarData.tomorrow; - this.getStellarDataFromYr(tomorrow) - .then((data) => { - if (data) { - data.date = tomorrow; - stellarData.tomorrow = data; - this.cacheStellarData(stellarData); - resolve(stellarData); - } else { - reject(`No stellar data returned from Yr for ${tomorrow}`); - } - }) - .catch((err) => { - Log.error("[weatherprovider.yr] getStellarDataFromYr error: ", err); - reject(`Unable to get stellar data from Yr for ${tomorrow}`); - }) - .finally(() => { - localStorage.removeItem("yrIsFetchingStellarData"); - }); - } else { - this.getStellarDataFromYr(today, 2) - .then((stellarData) => { - if (stellarData) { - const data = { - today: stellarData - }; - data.tomorrow = Object.assign({}, data.today); - data.today.date = today; - data.tomorrow.date = tomorrow; - this.cacheStellarData(data); - resolve(data); - } else { - Log.error(`[weatherprovider.yr] Something went wrong when fetching stellar data. Responses: ${stellarData}`); - reject(stellarData); - } - }) - .catch((err) => { - Log.error("[weatherprovider.yr] getStellarDataFromYr error: ", err); - reject("Unable to get stellar data from Yr."); - }) - .finally(() => { - localStorage.removeItem("yrIsFetchingStellarData"); + + this.fetcher.on("error", (errorInfo) => { + if (this.onErrorCallback) { + this.onErrorCallback(errorInfo); + } + }); + } + + async #handleResponse (data, fromCache = false) { + try { + if (!data.properties || !data.properties.timeseries) { + throw new Error("Invalid weather data"); + } + + // Refresh stellar data if needed (new day or using cached weather data) + if (fromCache) { + await this.#fetchStellarData(); + } + + let weatherData; + + switch (this.config.type) { + case "current": + weatherData = this.#generateCurrentWeather(data); + break; + case "forecast": + case "daily": + weatherData = this.#generateForecast(data); + break; + case "hourly": + weatherData = this.#generateHourly(data); + break; + default: + throw new Error(`Unknown weather type: ${this.config.type}`); + } + + if (this.onDataCallback) { + this.onDataCallback(weatherData); + } + } catch (error) { + Log.error("[yr] Error processing weather data:", error); + if (this.onErrorCallback) { + this.onErrorCallback({ + message: error.message, + translationKey: "MODULE_ERROR_UNSPECIFIED" }); + } } - }, - - getStellarDataFromCache () { - const stellarData = localStorage.getItem("stellarData"); - if (stellarData) { - return JSON.parse(stellarData); - } else { - return undefined; - } - }, - - getStellarDataFromYr (date, days = 1) { - const requestHeaders = [{ name: "Accept", value: "application/json" }]; - return this.fetchData(this.getStellarDataUrl(date, days), "json", requestHeaders) - .then((data) => { - Log.debug("[weatherprovider.yr] Got stellar data from yr."); - return data; - }) - .catch((err) => { - Log.error("[weatherprovider.yr] Could not load weather data.", err); - throw new Error(err); - }); - }, + } - getStellarDataUrl (date, days) { - let { lat, lon, altitude } = this.getConfigOptions(); + #generateCurrentWeather (data) { + const now = new Date(); + const timeseries = data.properties.timeseries; - if (lat.includes(".") && lat.split(".")[1].length > 4) { - Log.warn("[weatherprovider.yr] Latitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length."); - const latParts = lat.split("."); - lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`; - } - if (lon.includes(".") && lon.split(".")[1].length > 4) { - Log.warn("[weatherprovider.yr] Longitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length."); - const lonParts = lon.split("."); - lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`; - } + // Find closest forecast in the past + let forecast = timeseries[0]; + let closestDiff = Math.abs(now - new Date(forecast.time)); - let utcOffset = moment().utcOffset() / 60; - let utcOffsetPrefix = "%2B"; - if (utcOffset < 0) { - utcOffsetPrefix = "-"; - } - utcOffset = Math.abs(utcOffset); - let minutes = "00"; - if (utcOffset % 1 !== 0) { - minutes = "30"; + for (const entry of timeseries) { + const entryTime = new Date(entry.time); + const diff = now - entryTime; + + if (diff > 0 && diff < closestDiff) { + closestDiff = diff; + forecast = entry; + } } - let hours = Math.floor(utcOffset).toString(); - if (hours.length < 2) { - hours = `0${hours}`; + + const forecastXHours = this.#getForecastForXHours(forecast.data); + const stellarInfo = this.#getStellarInfoForDate(new Date(forecast.time)); + + const current = {}; + current.date = new Date(forecast.time); + current.temperature = forecast.data.instant.details.air_temperature; + current.windSpeed = forecast.data.instant.details.wind_speed; + current.windFromDirection = forecast.data.instant.details.wind_from_direction; + current.humidity = forecast.data.instant.details.relative_humidity; + current.weatherType = this.#convertWeatherType( + forecastXHours.summary?.symbol_code, + stellarInfo ? this.#isDayTime(current.date, stellarInfo) : true + ); + current.precipitationAmount = forecastXHours.details?.precipitation_amount; + current.precipitationProbability = forecastXHours.details?.probability_of_precipitation; + current.minTemperature = forecastXHours.details?.air_temperature_min; + current.maxTemperature = forecastXHours.details?.air_temperature_max; + + if (stellarInfo) { + current.sunrise = new Date(stellarInfo.sunrise.time); + current.sunset = new Date(stellarInfo.sunset.time); } - return `${this.config.apiBase}/sunrise/${this.config.sunriseApiVersion}/sun?lat=${lat}&lon=${lon}&date=${date}&offset=${utcOffsetPrefix}${hours}%3A${minutes}`; - }, - - cacheStellarData (data) { - localStorage.setItem("stellarData", JSON.stringify(data)); - }, - - getWeatherDataFrom (forecast, stellarData, units) { - const weather = new WeatherObject(); - - weather.date = moment(forecast.time); - weather.windSpeed = forecast.data.instant.details.wind_speed; - weather.windFromDirection = forecast.data.instant.details.wind_from_direction; - weather.temperature = forecast.data.instant.details.air_temperature; - weather.minTemperature = forecast.minTemperature; - weather.maxTemperature = forecast.maxTemperature; - weather.weatherType = forecast.weatherType; - weather.humidity = forecast.data.instant.details.relative_humidity; - weather.precipitationAmount = forecast.precipitationAmount; - weather.precipitationProbability = forecast.precipitationProbability; - weather.precipitationUnits = units.precipitation_amount; - - weather.sunrise = stellarData?.today?.properties?.sunrise?.time; - weather.sunset = stellarData?.today?.properties?.sunset?.time; - - return weather; - }, - - convertWeatherType (weatherType, weatherTime) { - const weatherHour = moment(weatherTime).format("HH"); - - const weatherTypes = { - clearsky_day: "day-sunny", - clearsky_night: "night-clear", - clearsky_polartwilight: weatherHour < 14 ? "sunrise" : "sunset", - cloudy: "cloudy", - fair_day: "day-sunny-overcast", - fair_night: "night-alt-partly-cloudy", - fair_polartwilight: "day-sunny-overcast", - fog: "fog", - heavyrain: "rain", // Possibly raindrops or raindrop - heavyrainandthunder: "thunderstorm", - heavyrainshowers_day: "day-rain", - heavyrainshowers_night: "night-alt-rain", - heavyrainshowers_polartwilight: "day-rain", - heavyrainshowersandthunder_day: "day-thunderstorm", - heavyrainshowersandthunder_night: "night-alt-thunderstorm", - heavyrainshowersandthunder_polartwilight: "day-thunderstorm", - heavysleet: "sleet", - heavysleetandthunder: "day-sleet-storm", - heavysleetshowers_day: "day-sleet", - heavysleetshowers_night: "night-alt-sleet", - heavysleetshowers_polartwilight: "day-sleet", - heavysleetshowersandthunder_day: "day-sleet-storm", - heavysleetshowersandthunder_night: "night-alt-sleet-storm", - heavysleetshowersandthunder_polartwilight: "day-sleet-storm", - heavysnow: "snow-wind", - heavysnowandthunder: "day-snow-thunderstorm", - heavysnowshowers_day: "day-snow-wind", - heavysnowshowers_night: "night-alt-snow-wind", - heavysnowshowers_polartwilight: "day-snow-wind", - heavysnowshowersandthunder_day: "day-snow-thunderstorm", - heavysnowshowersandthunder_night: "night-alt-snow-thunderstorm", - heavysnowshowersandthunder_polartwilight: "day-snow-thunderstorm", - lightrain: "rain-mix", - lightrainandthunder: "thunderstorm", - lightrainshowers_day: "day-rain-mix", - lightrainshowers_night: "night-alt-rain-mix", - lightrainshowers_polartwilight: "day-rain-mix", - lightrainshowersandthunder_day: "thunderstorm", - lightrainshowersandthunder_night: "thunderstorm", - lightrainshowersandthunder_polartwilight: "thunderstorm", - lightsleet: "day-sleet", - lightsleetandthunder: "day-sleet-storm", - lightsleetshowers_day: "day-sleet", - lightsleetshowers_night: "night-alt-sleet", - lightsleetshowers_polartwilight: "day-sleet", - lightsnow: "snowflake-cold", - lightsnowandthunder: "day-snow-thunderstorm", - lightsnowshowers_day: "day-snow-wind", - lightsnowshowers_night: "night-alt-snow-wind", - lightsnowshowers_polartwilight: "day-snow-wind", - lightssleetshowersandthunder_day: "day-sleet-storm", - lightssleetshowersandthunder_night: "night-alt-sleet-storm", - lightssleetshowersandthunder_polartwilight: "day-sleet-storm", - lightssnowshowersandthunder_day: "day-snow-thunderstorm", - lightssnowshowersandthunder_night: "night-alt-snow-thunderstorm", - lightssnowshowersandthunder_polartwilight: "day-snow-thunderstorm", - partlycloudy_day: "day-cloudy", - partlycloudy_night: "night-alt-cloudy", - partlycloudy_polartwilight: "day-cloudy", - rain: "rain", - rainandthunder: "thunderstorm", - rainshowers_day: "day-rain", - rainshowers_night: "night-alt-rain", - rainshowers_polartwilight: "day-rain", - rainshowersandthunder_day: "thunderstorm", - rainshowersandthunder_night: "lightning", - rainshowersandthunder_polartwilight: "thunderstorm", - sleet: "sleet", - sleetandthunder: "day-sleet-storm", - sleetshowers_day: "day-sleet", - sleetshowers_night: "night-alt-sleet", - sleetshowers_polartwilight: "day-sleet", - sleetshowersandthunder_day: "day-sleet-storm", - sleetshowersandthunder_night: "night-alt-sleet-storm", - sleetshowersandthunder_polartwilight: "day-sleet-storm", - snow: "snowflake-cold", - snowandthunder: "lightning", - snowshowers_day: "day-snow-wind", - snowshowers_night: "night-alt-snow-wind", - snowshowers_polartwilight: "day-snow-wind", - snowshowersandthunder_day: "day-snow-thunderstorm", - snowshowersandthunder_night: "night-alt-snow-thunderstorm", - snowshowersandthunder_polartwilight: "day-snow-thunderstorm" - }; - return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; - }, + return current; + } - getForecastForXHoursFrom (weather) { - if (this.config.currentForecastHours === 1) { - if (weather.next_1_hours) { - return weather.next_1_hours; - } else if (weather.next_6_hours) { - return weather.next_6_hours; - } else { - return weather.next_12_hours; + #generateForecast (data) { + const timeseries = data.properties.timeseries; + const dailyData = new Map(); + + // Collect all data points for each day + for (const entry of timeseries) { + const date = new Date(entry.time); + const dateStr = getDateString(date); + + if (!dailyData.has(dateStr)) { + dailyData.set(dateStr, { + date: date, + temps: [], + precip: [], + precipProb: [], + symbols: [] + }); } - } else if (this.config.currentForecastHours === 6) { - if (weather.next_6_hours) { - return weather.next_6_hours; - } else if (weather.next_12_hours) { - return weather.next_12_hours; - } else { - return weather.next_1_hours; + + const dayData = dailyData.get(dateStr); + + // Collect temperature from instant data + if (entry.data.instant?.details?.air_temperature !== undefined) { + dayData.temps.push(entry.data.instant.details.air_temperature); } - } else { - if (weather.next_12_hours) { - return weather.next_12_hours; - } else if (weather.next_6_hours) { - return weather.next_6_hours; - } else { - return weather.next_1_hours; + + // Collect data from forecast periods (prefer longer periods to avoid double-counting) + const forecast = entry.data.next_12_hours || entry.data.next_6_hours || entry.data.next_1_hours; + if (forecast) { + if (forecast.details?.precipitation_amount !== undefined) { + dayData.precip.push(forecast.details.precipitation_amount); + } + if (forecast.details?.probability_of_precipitation !== undefined) { + dayData.precipProb.push(forecast.details.probability_of_precipitation); + } + if (forecast.summary?.symbol_code) { + dayData.symbols.push(forecast.summary.symbol_code); + } } } - }, - - fetchWeatherHourly () { - this.getWeatherForecast("hourly") - .then((forecast) => { - this.setWeatherHourly(forecast); - this.updateAvailable(); - }) - .catch((error) => { - Log.error("[weatherprovider.yr] fetchWeatherHourly error: ", error); - this.updateAvailable(); - }); - }, - async getWeatherForecast (type) { - const [weatherData, stellarData] = await Promise.all([this.getWeatherData(), this.getStellarData()]); - if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) { - Log.error("[weatherprovider.yr] No weather data available."); - return; - } - if (!stellarData) { - Log.warn("[weatherprovider.yr] No stellar data available."); - } - let forecasts; - switch (type) { - case "hourly": - forecasts = this.getHourlyForecastFrom(weatherData); - break; - case "daily": - default: - forecasts = this.getDailyForecastFrom(weatherData); - break; + // Convert collected data to forecast objects + const days = []; + for (const [dateStr, data] of dailyData) { + const stellarInfo = this.#getStellarInfoForDate(data.date); + + const dayData = { + date: data.date, + minTemperature: data.temps.length > 0 ? Math.min(...data.temps) : null, + maxTemperature: data.temps.length > 0 ? Math.max(...data.temps) : null, + precipitationAmount: data.precip.length > 0 ? Math.max(...data.precip) : null, + precipitationProbability: data.precipProb.length > 0 ? Math.max(...data.precipProb) : null, + weatherType: data.symbols.length > 0 ? this.#convertWeatherType(data.symbols[0], true) : null + }; + + if (stellarInfo) { + dayData.sunrise = new Date(stellarInfo.sunrise.time); + dayData.sunset = new Date(stellarInfo.sunset.time); + } + + days.push(dayData); } - const series = []; - for (const forecast of forecasts) { - series.push(this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units)); + + // Sort by date to ensure correct order + return days.sort((a, b) => a.date - b.date); + } + + #generateHourly (data) { + const hours = []; + const timeseries = data.properties.timeseries; + + for (const entry of timeseries) { + const forecast1h = entry.data.next_1_hours; + if (!forecast1h) continue; + + const date = new Date(entry.time); + const stellarInfo = this.#getStellarInfoForDate(date); + + const hourly = { + date: date, + temperature: entry.data.instant.details.air_temperature, + windSpeed: entry.data.instant.details.wind_speed, + windFromDirection: entry.data.instant.details.wind_from_direction, + humidity: entry.data.instant.details.relative_humidity, + precipitationAmount: forecast1h.details?.precipitation_amount, + precipitationProbability: forecast1h.details?.probability_of_precipitation, + weatherType: this.#convertWeatherType( + forecast1h.summary?.symbol_code, + stellarInfo ? this.#isDayTime(date, stellarInfo) : true + ) + }; + + hours.push(hourly); } - return series; - }, - getHourlyForecastFrom (weatherData) { - const series = []; + return hours; + } - const now = moment({ - year: moment().year(), - month: moment().month(), - day: moment().date(), - hour: moment().hour() - }); - for (const forecast of weatherData.properties.timeseries) { - if (now.isAfter(moment(forecast.time))) continue; - - forecast.symbol = forecast.data.next_1_hours?.summary?.symbol_code; - forecast.precipitationAmount = forecast.data.next_1_hours?.details?.precipitation_amount; - forecast.precipitationProbability = forecast.data.next_1_hours?.details?.probability_of_precipitation; - forecast.minTemperature = forecast.data.next_1_hours?.details?.air_temperature_min; - forecast.maxTemperature = forecast.data.next_1_hours?.details?.air_temperature_max; - forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time); - series.push(forecast); + #getForecastForXHours (data) { + const hours = this.config.currentForecastHours; + + if (hours === 12 && data.next_12_hours) { + return data.next_12_hours; + } else if (hours === 6 && data.next_6_hours) { + return data.next_6_hours; + } else if (data.next_1_hours) { + return data.next_1_hours; } - return series; - }, - - getDailyForecastFrom (weatherData) { - const series = []; - - const days = weatherData.properties.timeseries.reduce(function (days, forecast) { - const date = moment(forecast.time).format("YYYY-MM-DD"); - days[date] = days[date] || []; - days[date].push(forecast); - return days; - }, Object.create(null)); - - Object.keys(days).forEach(function (time) { - let minTemperature = undefined; - let maxTemperature = undefined; - - //Default to first entry - let forecast = days[time][0]; - forecast.symbol = forecast.data.next_12_hours?.summary?.symbol_code; - forecast.precipitation = forecast.data.next_12_hours?.details?.precipitation_amount; - - //Coming days - let forecastDiffToEight = undefined; - for (const timeseries of days[time]) { - if (!timeseries.data.next_6_hours) continue; //next_6_hours has the most data - - if (!minTemperature || timeseries.data.next_6_hours.details.air_temperature_min < minTemperature) minTemperature = timeseries.data.next_6_hours.details.air_temperature_min; - if (!maxTemperature || maxTemperature < timeseries.data.next_6_hours.details.air_temperature_max) maxTemperature = timeseries.data.next_6_hours.details.air_temperature_max; - - let closestTime = Math.abs(moment(timeseries.time).local().set({ hour: 8, minute: 0, second: 0, millisecond: 0 }).diff(moment(timeseries.time).local())); - if ((forecastDiffToEight === undefined || closestTime < forecastDiffToEight) && timeseries.data.next_12_hours) { - forecastDiffToEight = closestTime; - forecast = timeseries; - } - } - const forecastXHours = forecast.data.next_12_hours ?? forecast.data.next_6_hours ?? forecast.data.next_1_hours; - if (forecastXHours) { - forecast.symbol = forecastXHours.summary?.symbol_code; - forecast.precipitationAmount = forecastXHours.details?.precipitation_amount ?? forecast.data.next_6_hours?.details?.precipitation_amount; // 6 hours is likely to have precipitation amount even if 12 hours does not - forecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation; - forecast.minTemperature = minTemperature; - forecast.maxTemperature = maxTemperature; - - series.push(forecast); + + return data.next_6_hours || data.next_12_hours || data.next_1_hours || {}; + } + + #getStellarInfoForDate (date) { + if (!this.stellarData) return null; + + const dateStr = getDateString(date); + + for (const day of this.stellarData) { + const dayDate = day.date.split("T")[0]; + if (dayDate === dateStr) { + return day; } - }); - for (const forecast of series) { - forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time); } - return series; - }, - - fetchWeatherForecast () { - this.getWeatherForecast("daily") - .then((forecast) => { - this.setWeatherForecast(forecast); - this.updateAvailable(); - }) - .catch((error) => { - Log.error("[weatherprovider.yr] fetchWeatherForecast error: ", error); - this.updateAvailable(); - }); + + return null; + } + + #isDayTime (date, stellarInfo) { + if (!stellarInfo || !stellarInfo.sunrise || !stellarInfo.sunset) { + return true; + } + + const sunrise = new Date(stellarInfo.sunrise.time); + const sunset = new Date(stellarInfo.sunset.time); + + return date >= sunrise && date < sunset; } -}); + + #convertWeatherType (symbolCode, isDayTime) { + if (!symbolCode) return null; + + // Yr.no uses symbol codes like "clearsky_day", "partlycloudy_night", etc. + const symbol = symbolCode.replace(/_day|_night/g, ""); + + const mappings = { + clearsky: isDayTime ? "day-sunny" : "night-clear", + fair: isDayTime ? "day-sunny" : "night-clear", + partlycloudy: isDayTime ? "day-cloudy" : "night-cloudy", + cloudy: "cloudy", + fog: "fog", + lightrainshowers: isDayTime ? "day-showers" : "night-showers", + rainshowers: isDayTime ? "showers" : "night-showers", + heavyrainshowers: isDayTime ? "day-rain" : "night-rain", + lightrain: isDayTime ? "day-sprinkle" : "night-sprinkle", + rain: isDayTime ? "rain" : "night-rain", + heavyrain: isDayTime ? "rain" : "night-rain", + lightsleetshowers: isDayTime ? "day-sleet" : "night-sleet", + sleetshowers: isDayTime ? "sleet" : "night-sleet", + heavysleetshowers: isDayTime ? "sleet" : "night-sleet", + lightsleet: isDayTime ? "day-sleet" : "night-sleet", + sleet: "sleet", + heavysleet: "sleet", + lightsnowshowers: isDayTime ? "day-snow" : "night-snow", + snowshowers: isDayTime ? "snow" : "night-snow", + heavysnowshowers: isDayTime ? "snow" : "night-snow", + lightsnow: isDayTime ? "day-snow" : "night-snow", + snow: "snow", + heavysnow: "snow", + lightrainandthunder: isDayTime ? "day-thunderstorm" : "night-thunderstorm", + rainandthunder: isDayTime ? "thunderstorm" : "night-thunderstorm", + heavyrainandthunder: isDayTime ? "thunderstorm" : "night-thunderstorm", + lightsleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm", + sleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm", + heavysleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm", + lightsnowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm", + snowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm", + heavysnowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm" + }; + + return mappings[symbol] || null; + } + + #getForecastUrl () { + const { lat, lon, altitude } = this.config; + return `${this.config.apiBase}/locationforecast/${this.config.forecastApiVersion}/complete?altitude=${altitude}&lat=${lat}&lon=${lon}`; + } + + #getSunriseUrl () { + const { lat, lon } = this.config; + const today = getDateString(new Date()); + const offset = formatTimezoneOffset(-new Date().getTimezoneOffset()); + return `${this.config.apiBase}/sunrise/${this.config.sunriseApiVersion}/sun?lat=${lat}&lon=${lon}&date=${today}&offset=${offset}`; + } +} + +module.exports = YrProvider; diff --git a/defaultmodules/weather/weather.js b/defaultmodules/weather/weather.js index 4b33682c21..fc06af3734 100644 --- a/defaultmodules/weather/weather.js +++ b/defaultmodules/weather/weather.js @@ -1,4 +1,4 @@ -/* global WeatherProvider, WeatherUtils, formatTime */ +/* global WeatherProvider, WeatherUtils, WeatherObject, formatTime */ Module.register("weather", { // Default module config. @@ -45,8 +45,12 @@ Module.register("weather", { hourlyForecastIncrements: 1 }, - // Module properties. - weatherProvider: null, + // Module properties (all providers run server-side) + instanceId: null, + fetchedLocationName: null, + currentWeatherObject: null, + weatherForecastArray: null, + weatherHourlyArray: null, // Can be used by the provider to display location of event if nothing else is specified firstEvent: null, @@ -58,14 +62,18 @@ Module.register("weather", { // Return the scripts that are necessary for the weather module. getScripts () { - return ["moment.js", "weatherutils.js", "weatherobject.js", this.file("providers/overrideWrapper.js"), "weatherprovider.js", "suncalc.js", this.file(`providers/${this.config.weatherProvider.toLowerCase()}.js`)]; + // Only load client-side dependencies for rendering + // All providers run server-side via node_helper + return ["moment.js", "weatherutils.js", "weatherobject.js", "suncalc.js"]; }, // Override getHeader method. getHeader () { - if (this.config.appendLocationNameToHeader && this.weatherProvider) { - if (this.data.header) return `${this.data.header} ${this.weatherProvider.fetchedLocation()}`; - else return this.weatherProvider.fetchedLocation(); + if (this.config.appendLocationNameToHeader) { + const locationName = this.fetchedLocationName || ""; + + if (this.data.header && locationName) return `${this.data.header} ${locationName}`; + else if (locationName) return locationName; } return this.data.header ? this.data.header : ""; @@ -87,17 +95,30 @@ Module.register("weather", { this.config.showHumidity = this.config.showHumidity ? "wind" : "none"; } - // Initialize the weather provider. - this.weatherProvider = WeatherProvider.initialize(this.config.weatherProvider, this); + // All providers run server-side: generate unique instance ID and initialize via node_helper + this.instanceId = `${this.identifier}_${Date.now()}`; + + Log.log(`[weather] Initializing server-side provider with instance ID: ${this.instanceId}`); + + this.sendSocketNotification("INIT_WEATHER", { + instanceId: this.instanceId, + weatherProvider: this.config.weatherProvider, + ...this.config + }); - // Let the weather provider know we are starting. - this.weatherProvider.start(); + // Server-driven fetching - no client-side scheduling needed // Add custom filters this.addFilters(); + }, - // Schedule the first update. - this.scheduleUpdate(this.config.initialLoadDelay); + // Cleanup on module hide/suspend + stop () { + if (this.instanceId) { + this.sendSocketNotification("STOP_WEATHER", { + instanceId: this.instanceId + }); + } }, // Override notification handler. @@ -121,10 +142,71 @@ Module.register("weather", { this.indoorHumidity = this.roundValue(payload); this.updateDom(300); } else if (notification === "CURRENT_WEATHER_OVERRIDE" && this.config.allowOverrideNotification) { - this.weatherProvider.notificationReceived(payload); + // Override current weather with data from local sensors + if (this.currentWeatherObject) { + Object.assign(this.currentWeatherObject, payload); + this.updateDom(this.config.animationSpeed); + } + } + }, + + // Handle socket notifications from node_helper + socketNotificationReceived (notification, payload) { + if (payload.instanceId !== this.instanceId) { + return; + } + + if (notification === "WEATHER_INITIALIZED") { + Log.log(`[weather] Provider initialized, location: ${payload.locationName}`); + this.fetchedLocationName = payload.locationName; + this.updateDom(); + // Server-driven fetching - HTTPFetcher will send WEATHER_DATA automatically + } else if (notification === "WEATHER_DATA") { + this.handleWeatherData(payload); + } else if (notification === "WEATHER_ERROR") { + Log.error("[weather] Error from node_helper:", payload.error); } }, + handleWeatherData (payload) { + const { type, data } = payload; + + if (!data) { + return; + } + + // Convert plain objects to WeatherObject instances + switch (type) { + case "current": + this.currentWeatherObject = this.createWeatherObject(data); + break; + case "forecast": + case "daily": + this.weatherForecastArray = data.map((d) => this.createWeatherObject(d)); + break; + case "hourly": + this.weatherHourlyArray = data.map((d) => this.createWeatherObject(d)); + break; + default: + Log.warn(`Unknown weather data type: ${type}`); + break; + } + + this.updateAvailable(); + }, + + createWeatherObject (data) { + const weather = new WeatherObject(); + Object.assign(weather, { + ...data, + // Convert to moment objects for template compatibility + date: data.date ? moment(data.date) : null, + sunrise: data.sunrise ? moment(data.sunrise) : null, + sunset: data.sunset ? moment(data.sunset) : null + }); + return weather; + }, + // Select the template depending on the display type. getTemplate () { switch (this.config.type.toLowerCase()) { @@ -143,16 +225,12 @@ Module.register("weather", { // Add all the data to the template. getTemplateData () { - const currentData = this.weatherProvider.currentWeather(); - const forecastData = this.weatherProvider.weatherForecast(); - - // Skip some hourly forecast entries if configured - const hourlyData = this.weatherProvider.weatherHourly()?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1); + const hourlyData = this.weatherHourlyArray?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1); return { config: this.config, - current: currentData, - forecast: forecastData, + current: this.currentWeatherObject, + forecast: this.weatherForecastArray, hourly: hourlyData, indoor: { humidity: this.indoorHumidity, @@ -164,58 +242,50 @@ Module.register("weather", { // What to do when the weather provider has new information available? updateAvailable () { Log.log("[weather] New weather information available."); - // this value was changed from 0 to 300 to stabilize weather tests: this.updateDom(300); - this.scheduleUpdate(); - if (this.weatherProvider.currentWeather()) { - this.sendNotification("CURRENTWEATHER_TYPE", { type: this.weatherProvider.currentWeather().weatherType?.replace("-", "_") }); + const currentWeather = this.currentWeatherObject; + + if (currentWeather) { + this.sendNotification("CURRENTWEATHER_TYPE", { type: currentWeather.weatherType?.replace("-", "_") }); } const notificationPayload = { currentWeather: this.config.units === "imperial" - ? WeatherUtils.convertWeatherObjectToImperial(this.weatherProvider?.currentWeatherObject?.simpleClone()) ?? null - : this.weatherProvider?.currentWeatherObject?.simpleClone() ?? null, + ? WeatherUtils.convertWeatherObjectToImperial(currentWeather?.simpleClone()) ?? null + : currentWeather?.simpleClone() ?? null, forecastArray: this.config.units === "imperial" - ? this.weatherProvider?.weatherForecastArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] - : this.weatherProvider?.weatherForecastArray?.map((ar) => ar.simpleClone()) ?? [], + ? this.getForecastArray()?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] + : this.getForecastArray()?.map((ar) => ar.simpleClone()) ?? [], hourlyArray: this.config.units === "imperial" - ? this.weatherProvider?.weatherHourlyArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] - : this.weatherProvider?.weatherHourlyArray?.map((ar) => ar.simpleClone()) ?? [], - locationName: this.weatherProvider?.fetchedLocationName, - providerName: this.weatherProvider.providerName + ? this.getHourlyArray()?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] + : this.getHourlyArray()?.map((ar) => ar.simpleClone()) ?? [], + locationName: this.fetchedLocationName, + providerName: this.config.weatherProvider }; this.sendNotification("WEATHER_UPDATED", notificationPayload); }, - scheduleUpdate (delay = null) { - let nextLoad = this.config.updateInterval; - if (delay !== null && delay >= 0) { - nextLoad = delay; - } + getForecastArray () { + return this.weatherForecastArray; + }, - setTimeout(() => { - switch (this.config.type.toLowerCase()) { - case "current": - this.weatherProvider.fetchCurrentWeather(); - break; - case "hourly": - this.weatherProvider.fetchWeatherHourly(); - break; - case "daily": - case "forecast": - this.weatherProvider.fetchWeatherForecast(); - break; - default: - Log.error(`[weather] Invalid type ${this.config.type} configured (must be one of 'current', 'hourly', 'daily' or 'forecast')`); - } - }, nextLoad); + getHourlyArray () { + return this.weatherHourlyArray; }, + // scheduleUpdate removed - all providers use server-driven fetching via HTTPFetcher + roundValue (temperature) { + if (temperature === null || temperature === undefined) { + return ""; + } const decimals = this.config.roundTemp ? 0 : 1; const roundValue = parseFloat(temperature).toFixed(decimals); + if (roundValue === "NaN") { + return ""; + } return roundValue === "-0" ? 0 : roundValue; }, @@ -232,14 +302,18 @@ Module.register("weather", { function (value, type, valueUnit) { let formattedValue; if (type === "temperature") { - formattedValue = `${this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits))}°`; - if (this.config.degreeLabel) { - if (this.config.tempUnits === "metric") { - formattedValue += "C"; - } else if (this.config.tempUnits === "imperial") { - formattedValue += "F"; - } else { - formattedValue += "K"; + if (value === null || value === undefined) { + formattedValue = ""; + } else { + formattedValue = `${this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits))}°`; + if (this.config.degreeLabel) { + if (this.config.tempUnits === "metric") { + formattedValue += "C"; + } else if (this.config.tempUnits === "imperial") { + formattedValue += "F"; + } else { + formattedValue += "K"; + } } } } else if (type === "precip") { diff --git a/defaultmodules/weather/weatherobject.js b/defaultmodules/weather/weatherobject.js index 5d6801ce13..77e88f634a 100644 --- a/defaultmodules/weather/weatherobject.js +++ b/defaultmodules/weather/weatherobject.js @@ -66,9 +66,13 @@ class WeatherObject { * the date from the weather-forecast. * @param {Moment} date an optional date where you want to get the next * action for. Useful only in tests, defaults to the current time. - * @returns {string} "sunset" or "sunrise" + * @returns {string|null} "sunset", "sunrise", or null if sun data unavailable */ nextSunAction (date = moment()) { + // Return null if sunrise/sunset data is unavailable + if (!this.sunrise || !this.sunset) { + return null; + } return date.isBetween(this.sunrise, this.sunset) ? "sunset" : "sunrise"; } @@ -84,6 +88,10 @@ class WeatherObject { * @returns {boolean} true if it is at dayTime */ isDayTime () { + // Default to daytime if sunrise/sunset data unavailable + if (!this.sunrise || !this.sunset) { + return true; + } const now = !this.date ? moment() : this.date; return now.isBetween(this.sunrise, this.sunset, undefined, "[]"); } diff --git a/defaultmodules/weather/weatherprovider.js b/defaultmodules/weather/weatherprovider.js deleted file mode 100644 index 629d7e19d1..0000000000 --- a/defaultmodules/weather/weatherprovider.js +++ /dev/null @@ -1,165 +0,0 @@ -/* global Class, performWebRequest, OverrideWrapper */ - -// This class is the blueprint for a weather provider. -const WeatherProvider = Class.extend({ - // Weather Provider Properties - providerName: null, - defaults: {}, - - // The following properties have accessor methods. - // Try to not access them directly. - currentWeatherObject: null, - weatherForecastArray: null, - weatherHourlyArray: null, - fetchedLocationName: null, - - // The following properties will be set automatically. - // You do not need to overwrite these properties. - config: null, - delegate: null, - providerIdentifier: null, - - // Weather Provider Methods - // All the following methods can be overwritten, although most are good as they are. - - // Called when a weather provider is initialized. - init (config) { - this.config = config; - Log.info(`[weatherprovider] ${this.providerName} initialized.`); - }, - - // Called to set the config, this config is the same as the weather module's config. - setConfig (config) { - this.config = config; - Log.info(`[weatherprovider] ${this.providerName} config set.`, this.config); - }, - - // Called when the weather provider is about to start. - start () { - Log.info(`[weatherprovider] ${this.providerName} started.`); - }, - - // This method should start the API request to fetch the current weather. - // This method should definitely be overwritten in the provider. - fetchCurrentWeather () { - Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchCurrentWeather method.`); - }, - - // This method should start the API request to fetch the weather forecast. - // This method should definitely be overwritten in the provider. - fetchWeatherForecast () { - Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchWeatherForecast method.`); - }, - - // This method should start the API request to fetch the weather hourly. - // This method should definitely be overwritten in the provider. - fetchWeatherHourly () { - Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchWeatherHourly method.`); - }, - - // This returns a WeatherDay object for the current weather. - currentWeather () { - return this.currentWeatherObject; - }, - - // This returns an array of WeatherDay objects for the weather forecast. - weatherForecast () { - return this.weatherForecastArray; - }, - - // This returns an object containing WeatherDay object(s) depending on the type of call. - weatherHourly () { - return this.weatherHourlyArray; - }, - - // This returns the name of the fetched location or an empty string. - fetchedLocation () { - return this.fetchedLocationName || ""; - }, - - // Set the currentWeather and notify the delegate that new information is available. - setCurrentWeather (currentWeatherObject) { - // We should check here if we are passing a WeatherDay - this.currentWeatherObject = currentWeatherObject; - }, - - // Set the weatherForecastArray and notify the delegate that new information is available. - setWeatherForecast (weatherForecastArray) { - // We should check here if we are passing a WeatherDay - this.weatherForecastArray = weatherForecastArray; - }, - - // Set the weatherHourlyArray and notify the delegate that new information is available. - setWeatherHourly (weatherHourlyArray) { - this.weatherHourlyArray = weatherHourlyArray; - }, - - // Set the fetched location name. - setFetchedLocation (name) { - this.fetchedLocationName = name; - }, - - // Notify the delegate that new weather is available. - updateAvailable () { - this.delegate.updateAvailable(this); - }, - - /** - * A convenience function to make requests. - * @param {string} url the url to fetch from - * @param {string} type what content-type to expect in the response, can be "json" or "xml" - * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send - * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive - * @returns {Promise} resolved when the fetch is done - */ - async fetchData (url, type = "json", requestHeaders = undefined, expectedResponseHeaders = undefined) { - const mockData = this.config.mockData; - if (mockData) { - const data = mockData.substring(1, mockData.length - 1); - return JSON.parse(data); - } - const useCorsProxy = typeof this.config.useCorsProxy !== "undefined" && this.config.useCorsProxy; - return performWebRequest(url, type, useCorsProxy, requestHeaders, expectedResponseHeaders, config.basePath); - } -}); - -/** - * Collection of registered weather providers. - */ -WeatherProvider.providers = []; - -/** - * Static method to register a new weather provider. - * @param {string} providerIdentifier The name of the weather provider - * @param {object} providerDetails The details of the weather provider - */ -WeatherProvider.register = function (providerIdentifier, providerDetails) { - WeatherProvider.providers[providerIdentifier.toLowerCase()] = WeatherProvider.extend(providerDetails); -}; - -/** - * Static method to initialize a new weather provider. - * @param {string} providerIdentifier The name of the weather provider - * @param {object} delegate The weather module - * @returns {object} The new weather provider - */ -WeatherProvider.initialize = function (providerIdentifier, delegate) { - const pi = providerIdentifier.toLowerCase(); - - const provider = new WeatherProvider.providers[pi](); - const config = Object.assign({}, provider.defaults, delegate.config); - - provider.delegate = delegate; - provider.setConfig(config); - - provider.providerIdentifier = pi; - if (!provider.providerName) { - provider.providerName = pi; - } - - if (config.allowOverrideNotification) { - return new OverrideWrapper(provider); - } - - return provider; -}; diff --git a/defaultmodules/weather/weatherutils.js b/defaultmodules/weather/weatherutils.js index 43a273b560..365441f0b5 100644 --- a/defaultmodules/weather/weatherutils.js +++ b/defaultmodules/weather/weatherutils.js @@ -25,6 +25,9 @@ const WeatherUtils = { * @returns {string} - A string with tha value and a unit postfix. */ convertPrecipitationUnit (value, valueUnit, outputUnit) { + if (value === null || value === undefined || isNaN(value)) { + return ""; + } if (valueUnit === "%") return `${value.toFixed(0)} ${valueUnit}`; let convertedValue = value; diff --git a/eslint.config.mjs b/eslint.config.mjs index b1c90ca6c3..cd2eb416e5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -51,7 +51,7 @@ export default defineConfig([ "@stylistic/space-before-function-paren": ["error", "always"], "@stylistic/spaced-comment": "off", "dot-notation": "error", - eqeqeq: "error", + eqeqeq: ["error", "always", { null: "ignore" }], "id-length": "off", "import-x/extensions": "error", "import-x/newline-after-import": "error", @@ -146,6 +146,15 @@ export default defineConfig([ "vitest/prefer-to-have-length": "error" } }, + { + files: ["tests/unit/modules/default/weather/providers/*.js"], + rules: { + "import-x/namespace": "off", + "import-x/named": "off", + "import-x/default": "off", + "import-x/extensions": "off" + } + }, { files: ["tests/configs/modules/weather/*.js"], rules: { diff --git a/js/http_fetcher.js b/js/http_fetcher.js index f5a56fc46a..86b95ea1ce 100644 --- a/js/http_fetcher.js +++ b/js/http_fetcher.js @@ -55,6 +55,7 @@ class HTTPFetcher extends EventEmitter { * @param {object} [options.headers] - Additional headers to send * @param {number} [options.maxRetries] - Max retries for 5xx errors (default: 3) * @param {number} [options.timeout] - Request timeout in ms (default: 30000) + * @param {string} [options.logContext] - Optional context for log messages (e.g., provider name) */ constructor (url, options = {}) { super(); @@ -66,6 +67,7 @@ class HTTPFetcher extends EventEmitter { this.customHeaders = options.headers || {}; this.maxRetries = options.maxRetries || MAX_SERVER_BACKOFF; this.timeout = options.timeout || DEFAULT_TIMEOUT; + this.logContext = options.logContext ? `[${options.logContext}] ` : ""; this.reloadTimer = null; this.serverErrorCount = 0; @@ -177,29 +179,29 @@ class HTTPFetcher extends EventEmitter { if (status === 401 || status === 403) { errorType = "AUTH_FAILURE"; delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES); - message = `Authentication failed (${status}). Waiting ${Math.round(delay / 60000)} minutes before retry.`; - Log.error(`${this.url} - ${message}`); + message = `Authentication failed (${status}). Check your API key. Waiting ${Math.round(delay / 60000)} minutes before retry.`; + Log.error(`${this.logContext}${this.url} - ${message}`); } else if (status === 429) { errorType = "RATE_LIMITED"; const retryAfter = response.headers.get("retry-after"); const parsed = retryAfter ? this.#parseRetryAfter(retryAfter) : null; delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES); message = `Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`; - Log.warn(`${this.url} - ${message}`); + Log.warn(`${this.logContext}${this.url} - ${message}`); } else if (status >= 500) { errorType = "SERVER_ERROR"; this.serverErrorCount = Math.min(this.serverErrorCount + 1, this.maxRetries); delay = this.reloadInterval * Math.pow(2, this.serverErrorCount); message = `Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`; - Log.error(`${this.url} - ${message}`); + Log.error(`${this.logContext}${this.url} - ${message}`); } else if (status >= 400) { errorType = "CLIENT_ERROR"; delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES); message = `Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`; - Log.error(`${this.url} - ${message}`); + Log.error(`${this.logContext}${this.url} - ${message}`); } else { message = `Unexpected HTTP status ${status}.`; - Log.error(`${this.url} - ${message}`); + Log.error(`${this.logContext}${this.url} - ${message}`); } return { @@ -267,7 +269,15 @@ class HTTPFetcher extends EventEmitter { const isTimeout = error.name === "AbortError"; const message = isTimeout ? `Request timeout after ${this.timeout}ms` : `Network error: ${error.message}`; - Log.error(`${this.url} - ${message}`); + // Truncate URL for cleaner logs + let shortUrl = this.url; + try { + const urlObj = new URL(this.url); + shortUrl = `${urlObj.origin}${urlObj.pathname}${urlObj.search.length > 50 ? "?..." : urlObj.search}`; + } catch (urlError) { + // If URL parsing fails, use original URL + } + Log.error(`${this.logContext}${shortUrl} - ${message}`); const errorInfo = this.#createErrorInfo( message, diff --git a/js/server.js b/js/server.js index 3bf892097f..9eba4f97df 100644 --- a/js/server.js +++ b/js/server.js @@ -6,7 +6,7 @@ const express = require("express"); const helmet = require("helmet"); const socketio = require("socket.io"); const Log = require("logger"); -const { cors, getHtml, getVersion, getStartup, getEnvVars } = require("#server_functions"); +const { getHtml, getVersion, getEnvVars, cors } = require("#server_functions"); const { ipAccessControl } = require(`${__dirname}/ip_access_control`); @@ -106,6 +106,9 @@ function Server (configObj) { app.use(directory, express.static(path.resolve(global.root_path + directory))); } + const startUp = new Date(); + const getStartup = (req, res) => res.send(startUp); + const getConfig = (req, res) => { if (config.hideConfigSecrets) { res.send(configObj.redactedConf); @@ -113,13 +116,12 @@ function Server (configObj) { res.send(configObj.fullConf); } }; + app.get("/config", (req, res) => getConfig(req, res)); app.get("/cors", async (req, res) => await cors(req, res)); app.get("/version", (req, res) => getVersion(req, res)); - app.get("/config", (req, res) => getConfig(req, res)); - app.get("/startup", (req, res) => getStartup(req, res)); app.get("/env", (req, res) => getEnvVars(req, res)); diff --git a/tests/configs/modules/weather/currentweather_compliments.js b/tests/configs/modules/weather/currentweather_compliments.js index 603fafa173..70bb1b8f01 100644 --- a/tests/configs/modules/weather/currentweather_compliments.js +++ b/tests/configs/modules/weather/currentweather_compliments.js @@ -16,10 +16,10 @@ let config = { module: "weather", position: "bottom_bar", config: { - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/weather", - mockData: '"#####WEATHERDATA#####"' + apiKey: "test-api-key" } } ] diff --git a/tests/configs/modules/weather/currentweather_default.js b/tests/configs/modules/weather/currentweather_default.js index e5a9fdce4e..0e6e9f1752 100644 --- a/tests/configs/modules/weather/currentweather_default.js +++ b/tests/configs/modules/weather/currentweather_default.js @@ -8,11 +8,11 @@ let config = { module: "weather", position: "bottom_bar", config: { - location: "Munich", + lat: 48.14, + lon: 11.58, showHumidity: "feelslike", weatherProvider: "openweathermap", - weatherEndpoint: "/weather", - mockData: '"#####WEATHERDATA#####"' + apiKey: "test-api-key" } } ] diff --git a/tests/configs/modules/weather/currentweather_options.js b/tests/configs/modules/weather/currentweather_options.js index 0ddb8b7cf2..814fca5520 100644 --- a/tests/configs/modules/weather/currentweather_options.js +++ b/tests/configs/modules/weather/currentweather_options.js @@ -6,10 +6,10 @@ let config = { module: "weather", position: "bottom_bar", config: { - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/weather", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", windUnits: "beaufort", showWindDirectionAsArrow: true, showSun: false, diff --git a/tests/configs/modules/weather/currentweather_units.js b/tests/configs/modules/weather/currentweather_units.js index 462b67f682..33baecd6b1 100644 --- a/tests/configs/modules/weather/currentweather_units.js +++ b/tests/configs/modules/weather/currentweather_units.js @@ -8,10 +8,10 @@ let config = { module: "weather", position: "bottom_bar", config: { - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/weather", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", decimalSymbol: ",", showHumidity: "wind" } diff --git a/tests/configs/modules/weather/forecastweather_absolute.js b/tests/configs/modules/weather/forecastweather_absolute.js index ff18bdf973..01fc4f43a9 100644 --- a/tests/configs/modules/weather/forecastweather_absolute.js +++ b/tests/configs/modules/weather/forecastweather_absolute.js @@ -9,10 +9,10 @@ let config = { position: "bottom_bar", config: { type: "forecast", - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/forecast/daily", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", absoluteDates: true } } diff --git a/tests/configs/modules/weather/forecastweather_default.js b/tests/configs/modules/weather/forecastweather_default.js index a53ba1273b..4cb23763bc 100644 --- a/tests/configs/modules/weather/forecastweather_default.js +++ b/tests/configs/modules/weather/forecastweather_default.js @@ -9,10 +9,10 @@ let config = { position: "bottom_bar", config: { type: "forecast", - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/forecast/daily", - mockData: '"#####WEATHERDATA#####"' + apiKey: "test-api-key" } } ] diff --git a/tests/configs/modules/weather/forecastweather_options.js b/tests/configs/modules/weather/forecastweather_options.js index 0e80198814..25ff5bcde0 100644 --- a/tests/configs/modules/weather/forecastweather_options.js +++ b/tests/configs/modules/weather/forecastweather_options.js @@ -9,10 +9,10 @@ let config = { position: "bottom_bar", config: { type: "forecast", - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/forecast/daily", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", showPrecipitationAmount: true, colored: true, tableClass: "myTableClass" diff --git a/tests/configs/modules/weather/forecastweather_units.js b/tests/configs/modules/weather/forecastweather_units.js index 73bcde9727..a71afaad56 100644 --- a/tests/configs/modules/weather/forecastweather_units.js +++ b/tests/configs/modules/weather/forecastweather_units.js @@ -9,10 +9,10 @@ let config = { position: "bottom_bar", config: { type: "forecast", - location: "Munich", + lat: 48.14, + lon: 11.58, weatherProvider: "openweathermap", - weatherEndpoint: "/forecast/daily", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", decimalSymbol: "_", showPrecipitationAmount: true } diff --git a/tests/configs/modules/weather/hourlyweather_default.js b/tests/configs/modules/weather/hourlyweather_default.js index 191ceab1d2..e7437b0920 100644 --- a/tests/configs/modules/weather/hourlyweather_default.js +++ b/tests/configs/modules/weather/hourlyweather_default.js @@ -8,11 +8,11 @@ let config = { module: "weather", position: "bottom_bar", config: { + lat: 48.14, + lon: 11.58, type: "hourly", - location: "Berlin", weatherProvider: "openweathermap", - weatherEndpoint: "/onecall", - mockData: '"#####WEATHERDATA#####"' + apiKey: "test-api-key" } } ] diff --git a/tests/configs/modules/weather/hourlyweather_options.js b/tests/configs/modules/weather/hourlyweather_options.js index c11d23dbd3..0e323a9f7f 100644 --- a/tests/configs/modules/weather/hourlyweather_options.js +++ b/tests/configs/modules/weather/hourlyweather_options.js @@ -8,11 +8,11 @@ let config = { module: "weather", position: "bottom_bar", config: { + lat: 48.14, + lon: 11.58, type: "hourly", - location: "Berlin", weatherProvider: "openweathermap", - weatherEndpoint: "/onecall", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", hourlyForecastIncrements: 2 } } diff --git a/tests/configs/modules/weather/hourlyweather_showPrecipitation.js b/tests/configs/modules/weather/hourlyweather_showPrecipitation.js index 3dbbc41837..bc04a9917d 100644 --- a/tests/configs/modules/weather/hourlyweather_showPrecipitation.js +++ b/tests/configs/modules/weather/hourlyweather_showPrecipitation.js @@ -8,11 +8,11 @@ let config = { module: "weather", position: "bottom_bar", config: { + lat: 48.14, + lon: 11.58, type: "hourly", - location: "Berlin", weatherProvider: "openweathermap", - weatherEndpoint: "/onecall", - mockData: '"#####WEATHERDATA#####"', + apiKey: "test-api-key", showPrecipitationAmount: true, showPrecipitationProbability: true } diff --git a/tests/e2e/helpers/weather-functions.js b/tests/e2e/helpers/weather-functions.js index 6780ea42c1..8eb0c0699e 100644 --- a/tests/e2e/helpers/weather-functions.js +++ b/tests/e2e/helpers/weather-functions.js @@ -1,12 +1,108 @@ -const { injectMockData, cleanupMockData } = require("../../utils/weather_mocker"); +const fs = require("node:fs"); +const path = require("node:path"); +const weatherUtils = require("../../../defaultmodules/weather/provider-utils"); const helpers = require("./global-setup"); -exports.startApplication = async (configFileName, additionalMockData) => { - await helpers.startApplication(injectMockData(configFileName, additionalMockData)); +/** + * Inject mock weather data directly via socket communication + * This bypasses the weather provider and tests only client-side rendering + * @param {object} page - Playwright page + * @param {string} mockDataFile - Filename of mock data + */ +async function injectMockWeatherData (page, mockDataFile) { + const rawData = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../mocks", mockDataFile)).toString()); + + // Validate that the fixture has at least one expected weather data type + if (!rawData.current && !rawData.daily && !rawData.hourly) { + throw new Error( + "Invalid weather fixture: missing current, daily, and hourly data. " + + `Available keys: ${Object.keys(rawData).join(", ")}` + ); + } + + // Determine weather type from the mock data structure + let type = "current"; + let data = null; + + const timezoneOffset = rawData.timezone_offset ? rawData.timezone_offset / 60 : 0; + + if (rawData.current) { + type = "current"; + // Mock what the provider would send for current weather + data = { + date: weatherUtils.applyTimezoneOffset(new Date(rawData.current.dt * 1000), timezoneOffset), + windSpeed: rawData.current.wind_speed, + windFromDirection: rawData.current.wind_deg, + sunrise: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunrise * 1000), timezoneOffset), + sunset: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunset * 1000), timezoneOffset), + temperature: rawData.current.temp, + weatherType: weatherUtils.convertWeatherType(rawData.current.weather[0].icon), + humidity: rawData.current.humidity, + feelsLikeTemp: rawData.current.feels_like + }; + } else if (rawData.daily) { + type = "forecast"; + data = rawData.daily.map((day) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset), + minTemperature: day.temp.min, + maxTemperature: day.temp.max, + weatherType: weatherUtils.convertWeatherType(day.weather[0].icon), + rain: day.rain || 0, + snow: day.snow || 0, + precipitationAmount: (day.rain || 0) + (day.snow || 0) + })); + } else if (rawData.hourly) { + type = "hourly"; + data = rawData.hourly.map((hour) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset), + temperature: hour.temp, + feelsLikeTemp: hour.feels_like, + humidity: hour.humidity, + windSpeed: hour.wind_speed, + windFromDirection: hour.wind_deg, + weatherType: weatherUtils.convertWeatherType(hour.weather[0].icon), + precipitationProbability: hour.pop != null ? hour.pop * 100 : undefined, + precipitationAmount: (hour.rain?.["1h"] || 0) + (hour.snow?.["1h"] || 0) + })); + } + + // Inject weather data by evaluating code in the browser context + await page.evaluate(({ type, data }) => { + // Find the weather module instance + const weatherModule = MM.getModules().find((m) => m.name === "weather"); + if (weatherModule) { + // Send INITIALIZED first + weatherModule.socketNotificationReceived("WEATHER_INITIALIZED", { + instanceId: weatherModule.instanceId, + locationName: "Munich" + }); + // Then send the actual data + weatherModule.socketNotificationReceived("WEATHER_DATA", { + instanceId: weatherModule.instanceId, + type: type, + data: data + }); + } + }, { type, data }); +} + +exports.startApplication = async (configFileName, mockDataFile) => { + await helpers.startApplication(configFileName); await helpers.getDocument(); + + // If mock data file is provided, inject it + if (mockDataFile) { + const page = helpers.getPage(); + // Wait for weather module to initialize + // eslint-disable-next-line playwright/no-wait-for-selector + await page.waitForSelector(".weather", { timeout: 5000 }); + await injectMockWeatherData(page, mockDataFile); + // Wait for data to be rendered + // eslint-disable-next-line playwright/no-wait-for-selector + await page.waitForSelector(".weather .weathericon", { timeout: 2000 }); + } }; exports.stopApplication = async () => { await helpers.stopApplication(); - cleanupMockData(); }; diff --git a/tests/e2e/modules/weather_current_spec.js b/tests/e2e/modules/weather_current_spec.js index 9b2928792e..bb8b63e2ac 100644 --- a/tests/e2e/modules/weather_current_spec.js +++ b/tests/e2e/modules/weather_current_spec.js @@ -12,7 +12,7 @@ describe("Weather module", () => { describe("Current weather", () => { describe("Default configuration", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_default.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_default.js", "weather_onecall_current.json"); page = helpers.getPage(); }); @@ -38,7 +38,7 @@ describe("Weather module", () => { describe("Compliments Integration", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_compliments.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_compliments.js", "weather_onecall_current.json"); page = helpers.getPage(); }); @@ -51,7 +51,7 @@ describe("Weather module", () => { describe("Configuration Options", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_options.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_options.js", "weather_onecall_current.json"); page = helpers.getPage(); }); @@ -79,7 +79,7 @@ describe("Weather module", () => { describe("Current weather with imperial units", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_units.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_units.js", "weather_onecall_current.json"); page = helpers.getPage(); }); diff --git a/tests/e2e/modules/weather_forecast_spec.js b/tests/e2e/modules/weather_forecast_spec.js index 011ed35f49..435cc98ce5 100644 --- a/tests/e2e/modules/weather_forecast_spec.js +++ b/tests/e2e/modules/weather_forecast_spec.js @@ -11,7 +11,7 @@ describe("Weather module: Weather Forecast", () => { describe("Default configuration", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_default.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_default.js", "weather_onecall_forecast.json"); page = helpers.getPage(); }); @@ -58,7 +58,7 @@ describe("Weather module: Weather Forecast", () => { describe("Absolute configuration", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_absolute.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_absolute.js", "weather_onecall_forecast.json"); page = helpers.getPage(); }); @@ -73,7 +73,7 @@ describe("Weather module: Weather Forecast", () => { describe("Configuration Options", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_options.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_options.js", "weather_onecall_forecast.json"); page = helpers.getPage(); }); @@ -99,7 +99,7 @@ describe("Weather module: Weather Forecast", () => { describe("Forecast weather with imperial units", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_units.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_units.js", "weather_onecall_forecast.json"); page = helpers.getPage(); }); diff --git a/tests/e2e/modules/weather_hourly_spec.js b/tests/e2e/modules/weather_hourly_spec.js index a33503f3b2..d84bd69e72 100644 --- a/tests/e2e/modules/weather_hourly_spec.js +++ b/tests/e2e/modules/weather_hourly_spec.js @@ -11,7 +11,7 @@ describe("Weather module: Weather Hourly Forecast", () => { describe("Default configuration", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_default.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_default.js", "weather_onecall_hourly.json"); page = helpers.getPage(); }); @@ -26,7 +26,7 @@ describe("Weather module: Weather Hourly Forecast", () => { describe("Hourly weather options", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_options.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_options.js", "weather_onecall_hourly.json"); page = helpers.getPage(); }); @@ -43,7 +43,7 @@ describe("Weather module: Weather Hourly Forecast", () => { describe("Show precipitations", () => { beforeAll(async () => { - await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_showPrecipitation.js", {}); + await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_showPrecipitation.js", "weather_onecall_hourly.json"); page = helpers.getPage(); }); diff --git a/tests/electron/helpers/weather-setup.js b/tests/electron/helpers/weather-setup.js index cb43054897..3bddaef1a1 100644 --- a/tests/electron/helpers/weather-setup.js +++ b/tests/electron/helpers/weather-setup.js @@ -1,6 +1,77 @@ -const { injectMockData } = require("../../utils/weather_mocker"); +const fs = require("node:fs"); +const path = require("node:path"); +const weatherUtils = require("../../../defaultmodules/weather/provider-utils"); const helpers = require("./global-setup"); +/** + * Inject mock weather data directly via socket communication + * This bypasses the weather provider and tests only client-side rendering + * @param {string} mockDataFile - Filename of mock data in tests/mocks + */ +async function injectMockWeatherData (mockDataFile) { + const rawData = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../mocks", mockDataFile)).toString()); + + const timezoneOffset = rawData.timezone_offset ? rawData.timezone_offset / 60 : 0; + + let type = "current"; + let data = null; + + if (rawData.current) { + type = "current"; + data = { + date: weatherUtils.applyTimezoneOffset(new Date(rawData.current.dt * 1000), timezoneOffset), + windSpeed: rawData.current.wind_speed, + windFromDirection: rawData.current.wind_deg, + sunrise: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunrise * 1000), timezoneOffset), + sunset: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunset * 1000), timezoneOffset), + temperature: rawData.current.temp, + weatherType: weatherUtils.convertWeatherType(rawData.current.weather[0].icon), + humidity: rawData.current.humidity, + feelsLikeTemp: rawData.current.feels_like + }; + } else if (rawData.daily) { + type = "forecast"; + data = rawData.daily.map((day) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset), + minTemperature: day.temp.min, + maxTemperature: day.temp.max, + weatherType: weatherUtils.convertWeatherType(day.weather[0].icon), + rain: day.rain || 0, + snow: day.snow || 0, + precipitationAmount: (day.rain || 0) + (day.snow || 0) + })); + } else if (rawData.hourly) { + type = "hourly"; + data = rawData.hourly.map((hour) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset), + temperature: hour.temp, + feelsLikeTemp: hour.feels_like, + humidity: hour.humidity, + windSpeed: hour.wind_speed, + windFromDirection: hour.wind_deg, + weatherType: weatherUtils.convertWeatherType(hour.weather[0].icon), + precipitationProbability: hour.pop != null ? hour.pop * 100 : undefined, + precipitationAmount: (hour.rain?.["1h"] || 0) + (hour.snow?.["1h"] || 0) + })); + } + + // Inject weather data by evaluating code in the browser context + await global.page.evaluate(({ type, data }) => { + const weatherModule = MM.getModules().find((m) => m.name === "weather"); + if (weatherModule) { + weatherModule.socketNotificationReceived("WEATHER_INITIALIZED", { + instanceId: weatherModule.instanceId, + locationName: "Munich" + }); + weatherModule.socketNotificationReceived("WEATHER_DATA", { + instanceId: weatherModule.instanceId, + type: type, + data: data + }); + } + }, { type, data }); +} + exports.getText = async (element, result) => { const elem = await helpers.getElement(element); await expect(elem).not.toBeNull(); @@ -14,6 +85,18 @@ exports.getText = async (element, result) => { return true; }; -exports.startApp = async (configFileName, systemDate) => { - await helpers.startApplication(injectMockData(configFileName), systemDate); +exports.startApp = async (configFileName, systemDate, mockDataFile = "weather_onecall_current.json") => { + await helpers.startApplication(configFileName, systemDate); + + // Wait for weather module to be present in DOM + await global.page.waitForSelector(".weather", { timeout: 5000 }); + + // Inject mock weather data + await injectMockWeatherData(mockDataFile); + + // Wait for weather content to be rendered + await global.page.waitForFunction(() => { + const weatherRoot = document.querySelector(".weather"); + return !!(weatherRoot && weatherRoot.textContent && weatherRoot.textContent.trim().length > 0); + }, { timeout: 5000 }); }; diff --git a/tests/electron/modules/weather_spec.js b/tests/electron/modules/weather_spec.js index fb362f805f..e300da90de 100644 --- a/tests/electron/modules/weather_spec.js +++ b/tests/electron/modules/weather_spec.js @@ -1,6 +1,5 @@ const helpers = require("../helpers/global-setup"); const weatherHelper = require("../helpers/weather-setup"); -const { cleanupMockData } = require("../../utils/weather_mocker"); const CURRENT_WEATHER_CONFIG = "tests/configs/modules/weather/currentweather_default.js"; const SUNRISE_DATE = "13 Jan 2019 00:30:00 GMT"; @@ -12,7 +11,6 @@ const EXPECTED_SUNSET_TEXT = "3:45 pm"; describe("Weather module", () => { afterEach(async () => { await helpers.stopApplication(); - cleanupMockData(); }); describe("Current weather with sunrise", () => { diff --git a/tests/mocks/weather_envcanada.xml b/tests/mocks/weather_envcanada.xml new file mode 100644 index 0000000000..ac8d6fcff3 --- /dev/null +++ b/tests/mocks/weather_envcanada.xml @@ -0,0 +1,871 @@ + + + https://dd.weather.gc.ca/doc/LICENCE_GENERAL.txt + + 2026 + 02 + 07 + 12 + 04 + 20260207120421 + Saturday February 07, 2026 at 12:04 UTC + + + 2026 + 02 + 07 + 07 + 04 + 20260207070421 + Saturday February 07, 2026 at 07:04 EST + + + North America + Canada + Ontario + Toronto + City of Toronto + + + + + 2026 + 02 + 07 + 09 + 06 + 20260207090653 + Saturday February 07, 2026 at 09:06 UTC + + + 2026 + 02 + 07 + 04 + 06 + 20260207040653 + Saturday February 07, 2026 at 04:06 EST + + + + + Toronto Pearson Int'l Airport + + 2026 + 02 + 07 + 12 + 00 + 20260207120000 + Saturday February 07, 2026 at 12:00 UTC + + + 2026 + 02 + 07 + 07 + 00 + 20260207070000 + Saturday February 07, 2026 at 07:00 EST + + Blowing Snow + 40 + -20.3 + -24.9 + -31 + 102.1 + 9.7 + 67 + + 19 + 33 + NNW + 346.0 + + + + + 2026 + 02 + 07 + 10 + 00 + 20260207100000 + Saturday February 07, 2026 at 10:00 UTC + + + 2026 + 02 + 07 + 05 + 00 + 20260207050000 + Saturday February 07, 2026 at 05:00 EST + + + Low minus 9. High minus 2. + -2 + -9 + + + Saturday + A mix of sun and cloud. 40 percent chance of flurries early this morning. Wind northwest 30 km/h gusting to 50. High minus 13. Wind chill minus 33 this morning and minus 22 this afternoon. Risk of frostbite. UV index 1 or low. + + A mix of sun and cloud. 40 percent chance of flurries early this morning. + + + 08 + 40 + Chance of flurries + + + High minus 13. + -13 + + + Wind northwest 30 km/h gusting to 50. + + 30 + 50 + NW + 32 + + + + + snow + + + Wind chill minus 33 this morning and minus 22 this afternoon. Risk of frostbite. + -33 + -22 + Risk of frostbite + + + 1 + UV index 1 or low. + + 40 + + + + Saturday night + Partly cloudy. Clearing late this evening. Wind northwest 20 km/h becoming light late this evening. Low minus 21. Wind chill minus 22 this evening and minus 28 overnight. Risk of frostbite. + + Partly cloudy. Clearing late this evening. + + + 35 + + Clearing + + + Low minus 21. + -21 + + + Wind northwest 20 km/h becoming light late this evening. + + 20 + 00 + NW + 32 + + + 10 + 00 + NW + 32 + + + + + + + + Wind chill minus 22 this evening and minus 28 overnight. Risk of frostbite. + -22 + -28 + Risk of frostbite + + 65 + + + + Sunday + Sunny. Wind up to 15 km/h. High minus 12. Wind chill minus 28 in the morning and minus 19 in the afternoon. Risk of frostbite. UV index 2 or low. + + Sunny. + + + 00 + + Sunny + + + High minus 12. + -12 + + + Wind up to 15 km/h. + + 10 + 00 + N + 36 + + + 15 + 00 + NW + 32 + + + + + + + + Wind chill minus 28 in the morning and minus 19 in the afternoon. Risk of frostbite. + -28 + -19 + Risk of frostbite + + + 2 + UV index 2 or low. + + 55 + + + + Sunday night + Cloudy periods. Low minus 14. + + Cloudy periods. + + + 32 + + Cloudy periods + + + Low minus 14. + -14 + + + + + + + + 60 + + + + Monday + Cloudy with 40 percent chance of flurries. High minus 6. + + Cloudy with 40 percent chance of flurries. + + + 16 + 40 + Chance of flurries + + + High minus 6. + -6 + + + + + snow + + + 65 + + + + Monday night + Cloudy with 30 percent chance of flurries. Low minus 8. + + Cloudy with 30 percent chance of flurries. + + + 16 + 30 + Chance of flurries + + + Low minus 8. + -8 + + + + + snow + + + 65 + + + + Tuesday + Cloudy with 30 percent chance of flurries. High minus 2. + + Cloudy with 30 percent chance of flurries. + + + 16 + 30 + Chance of flurries + + + High minus 2. + -2 + + + + + snow + + + 75 + + + + Tuesday night + Cloudy with 40 percent chance of flurries. Low minus 3. + + Cloudy with 40 percent chance of flurries. + + + 16 + 40 + Chance of flurries + + + Low minus 3. + -3 + + + + + snow + + + 80 + + + + Wednesday + Cloudy with 30 percent chance of flurries. High zero. + + Cloudy with 30 percent chance of flurries. + + + 16 + 30 + Chance of flurries + + + High zero. + 0 + + + + + snow + + + 80 + + + + Wednesday night + Cloudy periods. Low minus 6. + + Cloudy periods. + + + 32 + + Cloudy periods + + + Low minus 6. + -6 + + + + + + + + 90 + + + + Thursday + A mix of sun and cloud. High minus 2. + + A mix of sun and cloud. + + + 02 + + A mix of sun and cloud + + + High minus 2. + -2 + + + + + + + + 75 + + + + Thursday night + Cloudy periods. Low minus 7. + + Cloudy periods. + + + 32 + + Cloudy periods + + + Low minus 7. + -7 + + + + + + + + 75 + + + + Friday + A mix of sun and cloud. High minus 1. + + A mix of sun and cloud. + + + 02 + + A mix of sun and cloud + + + High minus 1. + -1 + + + + + + + + 75 + + + + + + 2026 + 02 + 07 + 10 + 00 + 20260207100000 + Saturday February 07, 2026 at 10:00 UTC + + + 2026 + 02 + 07 + 05 + 00 + 20260207050000 + Saturday February 07, 2026 at 05:00 EST + + + A mix of sun and cloud + 02 + -20 + 10 + -32 + + + 30 + NW + 50 + + + + A mix of sun and cloud + 02 + -19 + 10 + -32 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -19 + 10 + -31 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -18 + 10 + -30 + + + 30 + NW + 50 + + + 1 + + + + Mainly sunny + 01 + -16 + 10 + -28 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -15 + 10 + -26 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -14 + 10 + -25 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -14 + 10 + -24 + + + 30 + NW + 50 + + + + A mix of sun and cloud + 02 + -13 + 10 + -23 + + + 30 + NW + 50 + + + + A mix of sun and cloud + 02 + -13 + 10 + -24 + + + 30 + NW + 50 + + + + Partly cloudy + 32 + -14 + 10 + -22 + + + 20 + NW + + + + + Partly cloudy + 32 + -14 + 10 + -23 + + + 20 + NW + + + + + Partly cloudy + 32 + -15 + 10 + -24 + + + 20 + NW + + + + + Partly cloudy + 32 + -16 + 0 + -25 + + + 20 + NW + + + + + Partly cloudy + 32 + -17 + 0 + -24 + + + 10 + NW + + + + + A few clouds + 31 + -18 + 0 + -24 + + + 10 + NW + + + + + A few clouds + 31 + -18 + 0 + -25 + + + 10 + NW + + + + + A few clouds + 31 + -19 + 0 + -26 + + + 10 + NW + + + + + Clear + 30 + -20 + 0 + -27 + + + 10 + NW + + + + + Clear + 30 + -20 + 0 + -28 + + + 10 + NW + + + + + Clear + 30 + -21 + 0 + -28 + + + 10 + NW + + + + + Clear + 30 + -21 + 0 + -28 + + + 10 + NW + + + + + Clear + 30 + -21 + 0 + -28 + + + 10 + N + + + + + Sunny + 00 + -21 + 0 + -28 + + + 10 + N + + + + + + The information provided here, for the times of the rise and set of the sun, is an estimate included as a convenience to our clients. Values shown here may differ from the official sunrise/sunset data available from (http://hia-iha.nrc-cnrc.gc.ca/sunrise_e.html) + + 2026 + 02 + 07 + 12 + 27 + 20260207122700 + Saturday February 07, 2026 at 12:27 UTC + + + 2026 + 02 + 07 + 07 + 27 + 20260207072700 + Saturday February 07, 2026 at 07:27 EST + + + 2026 + 02 + 07 + 22 + 37 + 20260207223700 + Saturday February 07, 2026 at 22:37 UTC + + + 2026 + 02 + 07 + 17 + 37 + 20260207173700 + Saturday February 07, 2026 at 17:37 EST + + + \ No newline at end of file diff --git a/tests/mocks/weather_envcanada_index.html b/tests/mocks/weather_envcanada_index.html new file mode 100644 index 0000000000..93d5a5d4a2 --- /dev/null +++ b/tests/mocks/weather_envcanada_index.html @@ -0,0 +1,427 @@ + + + + Index of /today/citypage_weather/ON/12 + + +

Index of /today/citypage_weather/ON/12

+
Icon  Name                                                     Last modified      Size  Description
[PARENTDIR] Parent Directory - +[   ] 20260207T120044.778Z_MSC_CitypageWeather_s0000024_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120044.827Z_MSC_CitypageWeather_s0000024_fr.xml 2026-02-07 12:00 36K +[   ] 20260207T120044.875Z_MSC_CitypageWeather_s0000022_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120044.919Z_MSC_CitypageWeather_s0000022_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120045.458Z_MSC_CitypageWeather_s0000588_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120045.502Z_MSC_CitypageWeather_s0000588_fr.xml 2026-02-07 12:00 36K +[   ] 20260207T120047.636Z_MSC_CitypageWeather_s0000546_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120047.636Z_MSC_CitypageWeather_s0000819_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120047.715Z_MSC_CitypageWeather_s0000546_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120047.715Z_MSC_CitypageWeather_s0000819_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120047.911Z_MSC_CitypageWeather_s0000646_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120047.960Z_MSC_CitypageWeather_s0000646_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120048.388Z_MSC_CitypageWeather_s0000512_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.388Z_MSC_CitypageWeather_s0000513_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.388Z_MSC_CitypageWeather_s0000790_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.526Z_MSC_CitypageWeather_s0000512_fr.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.526Z_MSC_CitypageWeather_s0000513_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120048.526Z_MSC_CitypageWeather_s0000790_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120048.664Z_MSC_CitypageWeather_s0000765_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120048.664Z_MSC_CitypageWeather_s0000766_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.752Z_MSC_CitypageWeather_s0000765_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120048.752Z_MSC_CitypageWeather_s0000766_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120048.945Z_MSC_CitypageWeather_s0000782_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.994Z_MSC_CitypageWeather_s0000782_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120049.038Z_MSC_CitypageWeather_s0000585_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.038Z_MSC_CitypageWeather_s0000785_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.127Z_MSC_CitypageWeather_s0000585_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120049.127Z_MSC_CitypageWeather_s0000785_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.536Z_MSC_CitypageWeather_s0000411_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120049.536Z_MSC_CitypageWeather_s0000659_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120049.536Z_MSC_CitypageWeather_s0000660_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.653Z_MSC_CitypageWeather_s0000411_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.653Z_MSC_CitypageWeather_s0000659_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120049.653Z_MSC_CitypageWeather_s0000660_fr.xml 2026-02-07 12:00 38K +[   ] 20260207T120052.583Z_MSC_CitypageWeather_s0000080_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120052.583Z_MSC_CitypageWeather_s0000454_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120052.665Z_MSC_CitypageWeather_s0000080_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120052.665Z_MSC_CitypageWeather_s0000454_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120055.400Z_MSC_CitypageWeather_s0000596_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120055.400Z_MSC_CitypageWeather_s0000597_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120055.471Z_MSC_CitypageWeather_s0000596_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120055.471Z_MSC_CitypageWeather_s0000597_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120117.907Z_MSC_CitypageWeather_s0000744_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120117.907Z_MSC_CitypageWeather_s0000796_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120118.045Z_MSC_CitypageWeather_s0000744_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120118.045Z_MSC_CitypageWeather_s0000796_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120118.904Z_MSC_CitypageWeather_s0000572_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120118.904Z_MSC_CitypageWeather_s0000573_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.006Z_MSC_CitypageWeather_s0000572_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.006Z_MSC_CitypageWeather_s0000573_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.181Z_MSC_CitypageWeather_s0000765_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.181Z_MSC_CitypageWeather_s0000766_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.228Z_MSC_CitypageWeather_s0000765_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.228Z_MSC_CitypageWeather_s0000766_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.276Z_MSC_CitypageWeather_s0000395_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.276Z_MSC_CitypageWeather_s0000520_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.351Z_MSC_CitypageWeather_s0000395_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.351Z_MSC_CitypageWeather_s0000520_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000724_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000725_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000726_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000727_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000728_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000724_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000725_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000726_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000727_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000728_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120120.713Z_MSC_CitypageWeather_s0000752_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120120.713Z_MSC_CitypageWeather_s0000753_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120120.815Z_MSC_CitypageWeather_s0000752_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120120.815Z_MSC_CitypageWeather_s0000753_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.465Z_MSC_CitypageWeather_s0000538_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.465Z_MSC_CitypageWeather_s0000539_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.544Z_MSC_CitypageWeather_s0000538_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.544Z_MSC_CitypageWeather_s0000539_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000637_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000638_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000639_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000640_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000641_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000642_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000637_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000638_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000639_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000640_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000641_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000642_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120123.442Z_MSC_CitypageWeather_s0000707_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.442Z_MSC_CitypageWeather_s0000708_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.442Z_MSC_CitypageWeather_s0000710_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.575Z_MSC_CitypageWeather_s0000707_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.575Z_MSC_CitypageWeather_s0000708_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.575Z_MSC_CitypageWeather_s0000710_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120124.159Z_MSC_CitypageWeather_s0000628_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120124.217Z_MSC_CitypageWeather_s0000628_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120125.748Z_MSC_CitypageWeather_s0000629_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.748Z_MSC_CitypageWeather_s0000631_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.748Z_MSC_CitypageWeather_s0000632_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.902Z_MSC_CitypageWeather_s0000629_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.902Z_MSC_CitypageWeather_s0000631_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.902Z_MSC_CitypageWeather_s0000632_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120126.136Z_MSC_CitypageWeather_s0000650_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120126.136Z_MSC_CitypageWeather_s0000651_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120126.234Z_MSC_CitypageWeather_s0000650_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120126.234Z_MSC_CitypageWeather_s0000651_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120126.402Z_MSC_CitypageWeather_s0000691_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120126.402Z_MSC_CitypageWeather_s0000692_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120126.591Z_MSC_CitypageWeather_s0000691_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120126.591Z_MSC_CitypageWeather_s0000692_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120127.205Z_MSC_CitypageWeather_s0000696_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.205Z_MSC_CitypageWeather_s0000697_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.205Z_MSC_CitypageWeather_s0000698_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120127.458Z_MSC_CitypageWeather_s0000696_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120127.458Z_MSC_CitypageWeather_s0000697_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.458Z_MSC_CitypageWeather_s0000698_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.975Z_MSC_CitypageWeather_s0000374_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.092Z_MSC_CitypageWeather_s0000374_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.201Z_MSC_CitypageWeather_s0000761_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.201Z_MSC_CitypageWeather_s0000762_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120128.201Z_MSC_CitypageWeather_s0000764_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.320Z_MSC_CitypageWeather_s0000761_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120128.320Z_MSC_CitypageWeather_s0000762_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.320Z_MSC_CitypageWeather_s0000764_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120129.744Z_MSC_CitypageWeather_s0000676_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120129.744Z_MSC_CitypageWeather_s0000677_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120129.871Z_MSC_CitypageWeather_s0000676_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120129.871Z_MSC_CitypageWeather_s0000677_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120130.576Z_MSC_CitypageWeather_s0000069_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120131.204Z_MSC_CitypageWeather_s0000069_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120131.735Z_MSC_CitypageWeather_s0000691_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120131.735Z_MSC_CitypageWeather_s0000692_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120131.790Z_MSC_CitypageWeather_s0000691_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120131.790Z_MSC_CitypageWeather_s0000692_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.173Z_MSC_CitypageWeather_s0000232_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120132.173Z_MSC_CitypageWeather_s0000233_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120132.291Z_MSC_CitypageWeather_s0000232_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120132.291Z_MSC_CitypageWeather_s0000233_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000325_en.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000326_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000327_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000328_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000329_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000325_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000326_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000327_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000328_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000329_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120133.465Z_MSC_CitypageWeather_s0000676_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.465Z_MSC_CitypageWeather_s0000677_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120133.514Z_MSC_CitypageWeather_s0000676_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120133.514Z_MSC_CitypageWeather_s0000677_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.562Z_MSC_CitypageWeather_s0000761_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120133.562Z_MSC_CitypageWeather_s0000762_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120133.562Z_MSC_CitypageWeather_s0000764_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.639Z_MSC_CitypageWeather_s0000761_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120133.639Z_MSC_CitypageWeather_s0000762_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.639Z_MSC_CitypageWeather_s0000764_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120136.411Z_MSC_CitypageWeather_s0000424_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120136.411Z_MSC_CitypageWeather_s0000425_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120136.411Z_MSC_CitypageWeather_s0000426_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120136.517Z_MSC_CitypageWeather_s0000424_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120136.517Z_MSC_CitypageWeather_s0000425_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120136.517Z_MSC_CitypageWeather_s0000426_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000724_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000725_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000726_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000727_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000728_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000724_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000725_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000726_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000727_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000728_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.808Z_MSC_CitypageWeather_s0000826_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120139.851Z_MSC_CitypageWeather_s0000826_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120147.835Z_MSC_CitypageWeather_s0000548_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120147.835Z_MSC_CitypageWeather_s0000549_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120147.928Z_MSC_CitypageWeather_s0000548_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120147.928Z_MSC_CitypageWeather_s0000549_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120148.018Z_MSC_CitypageWeather_s0000747_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120148.018Z_MSC_CitypageWeather_s0000748_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120148.089Z_MSC_CitypageWeather_s0000747_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120148.089Z_MSC_CitypageWeather_s0000748_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120148.161Z_MSC_CitypageWeather_s0000231_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120148.194Z_MSC_CitypageWeather_s0000231_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120150.484Z_MSC_CitypageWeather_s0000071_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120150.649Z_MSC_CitypageWeather_s0000071_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120153.235Z_MSC_CitypageWeather_s0000023_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.276Z_MSC_CitypageWeather_s0000023_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000072_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000077_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000434_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000435_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000436_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000072_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000077_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000434_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000435_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000436_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120153.926Z_MSC_CitypageWeather_s0000437_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.960Z_MSC_CitypageWeather_s0000437_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120154.081Z_MSC_CitypageWeather_s0000073_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120154.081Z_MSC_CitypageWeather_s0000074_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120154.081Z_MSC_CitypageWeather_s0000075_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120154.216Z_MSC_CitypageWeather_s0000073_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120154.216Z_MSC_CitypageWeather_s0000074_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120154.216Z_MSC_CitypageWeather_s0000075_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120205.167Z_MSC_CitypageWeather_s0000428_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120205.205Z_MSC_CitypageWeather_s0000428_fr.xml 2026-02-07 12:02 36K +[   ] 20260207T120207.885Z_MSC_CitypageWeather_s0000680_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120207.885Z_MSC_CitypageWeather_s0000843_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120207.961Z_MSC_CitypageWeather_s0000680_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120207.961Z_MSC_CitypageWeather_s0000843_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120210.405Z_MSC_CitypageWeather_s0000455_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120210.451Z_MSC_CitypageWeather_s0000455_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120210.675Z_MSC_CitypageWeather_s0000367_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120210.675Z_MSC_CitypageWeather_s0000368_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120210.758Z_MSC_CitypageWeather_s0000367_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120210.758Z_MSC_CitypageWeather_s0000368_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120211.358Z_MSC_CitypageWeather_s0000251_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120211.408Z_MSC_CitypageWeather_s0000251_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120221.954Z_MSC_CitypageWeather_s0000418_en.xml 2026-02-07 12:02 35K +[   ] 20260207T120221.954Z_MSC_CitypageWeather_s0000419_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.043Z_MSC_CitypageWeather_s0000418_fr.xml 2026-02-07 12:02 36K +[   ] 20260207T120222.043Z_MSC_CitypageWeather_s0000419_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.347Z_MSC_CitypageWeather_s0000451_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.347Z_MSC_CitypageWeather_s0000452_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.448Z_MSC_CitypageWeather_s0000451_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120222.448Z_MSC_CitypageWeather_s0000452_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120223.004Z_MSC_CitypageWeather_s0000550_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120223.050Z_MSC_CitypageWeather_s0000550_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120223.502Z_MSC_CitypageWeather_s0000763_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.539Z_MSC_CitypageWeather_s0000763_fr.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.577Z_MSC_CitypageWeather_s0000469_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120223.577Z_MSC_CitypageWeather_s0000470_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120223.660Z_MSC_CitypageWeather_s0000469_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120223.660Z_MSC_CitypageWeather_s0000470_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120223.753Z_MSC_CitypageWeather_s0000103_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.753Z_MSC_CitypageWeather_s0000105_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.839Z_MSC_CitypageWeather_s0000103_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120223.839Z_MSC_CitypageWeather_s0000105_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000528_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000529_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000530_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000531_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000528_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000529_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000530_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000531_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.518Z_MSC_CitypageWeather_s0000582_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.518Z_MSC_CitypageWeather_s0000584_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.518Z_MSC_CitypageWeather_s0000773_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.630Z_MSC_CitypageWeather_s0000582_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.630Z_MSC_CitypageWeather_s0000584_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.630Z_MSC_CitypageWeather_s0000773_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000235_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000236_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000237_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000238_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000240_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000235_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000236_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000237_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000238_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000240_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.829Z_MSC_CitypageWeather_s0000127_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120228.874Z_MSC_CitypageWeather_s0000127_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000700_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000701_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000702_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000703_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000704_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000700_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000701_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000702_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000703_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000704_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120240.742Z_MSC_CitypageWeather_s0000104_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120240.780Z_MSC_CitypageWeather_s0000104_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120252.049Z_MSC_CitypageWeather_s0000431_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120252.088Z_MSC_CitypageWeather_s0000431_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120253.001Z_MSC_CitypageWeather_s0000169_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120253.050Z_MSC_CitypageWeather_s0000169_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120255.032Z_MSC_CitypageWeather_s0000108_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120255.032Z_MSC_CitypageWeather_s0000429_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120255.032Z_MSC_CitypageWeather_s0000489_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120255.158Z_MSC_CitypageWeather_s0000108_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120255.158Z_MSC_CitypageWeather_s0000429_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120255.158Z_MSC_CitypageWeather_s0000489_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120305.479Z_MSC_CitypageWeather_s0000705_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120305.479Z_MSC_CitypageWeather_s0000706_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120305.555Z_MSC_CitypageWeather_s0000705_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120305.555Z_MSC_CitypageWeather_s0000706_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120307.995Z_MSC_CitypageWeather_s0000517_en.xml 2026-02-07 12:03 35K +[   ] 20260207T120308.045Z_MSC_CitypageWeather_s0000517_fr.xml 2026-02-07 12:03 36K +[   ] 20260207T120312.670Z_MSC_CitypageWeather_s0000076_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120312.703Z_MSC_CitypageWeather_s0000076_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120312.743Z_MSC_CitypageWeather_s0000281_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.743Z_MSC_CitypageWeather_s0000414_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.743Z_MSC_CitypageWeather_s0000415_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120312.872Z_MSC_CitypageWeather_s0000281_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.872Z_MSC_CitypageWeather_s0000414_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.872Z_MSC_CitypageWeather_s0000415_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000301_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000302_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000303_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000304_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000305_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000301_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000302_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000303_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000304_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000305_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120324.150Z_MSC_CitypageWeather_s0000168_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120324.203Z_MSC_CitypageWeather_s0000168_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120324.794Z_MSC_CitypageWeather_s0000165_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120324.794Z_MSC_CitypageWeather_s0000166_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120324.879Z_MSC_CitypageWeather_s0000165_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120324.879Z_MSC_CitypageWeather_s0000166_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120326.004Z_MSC_CitypageWeather_s0000266_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120326.004Z_MSC_CitypageWeather_s0000267_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120326.077Z_MSC_CitypageWeather_s0000266_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120326.077Z_MSC_CitypageWeather_s0000267_fr.xml 2026-02-07 12:03 36K +[   ] 20260207T120334.963Z_MSC_CitypageWeather_s0000571_en.xml 2026-02-07 12:04 38K +[   ] 20260207T120335.008Z_MSC_CitypageWeather_s0000571_fr.xml 2026-02-07 12:04 39K +[   ] 20260207T120407.043Z_MSC_CitypageWeather_s0000422_en.xml 2026-02-07 12:04 38K +[   ] 20260207T120407.081Z_MSC_CitypageWeather_s0000422_fr.xml 2026-02-07 12:04 39K +[   ] 20260207T120410.902Z_MSC_CitypageWeather_s0000070_en.xml 2026-02-07 12:04 36K +[   ] 20260207T120410.951Z_MSC_CitypageWeather_s0000070_fr.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000458_en.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000658_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000786_en.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000787_en.xml 2026-02-07 12:05 38K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000789_en.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000458_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000658_fr.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000786_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000787_fr.xml 2026-02-07 12:04 39K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000789_fr.xml 2026-02-07 12:04 37K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000729_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000730_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000731_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000732_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000729_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000730_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000731_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000732_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.127Z_MSC_CitypageWeather_s0000430_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.127Z_MSC_CitypageWeather_s0000623_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.217Z_MSC_CitypageWeather_s0000430_fr.xml 2026-02-07 12:05 38K +[   ] 20260207T120504.217Z_MSC_CitypageWeather_s0000623_fr.xml 2026-02-07 12:05 38K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000025_en.xml 2026-02-07 12:05 34K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000025_fr.xml 2026-02-07 12:05 35K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000088_en.xml 2026-02-07 12:05 34K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000088_fr.xml 2026-02-07 12:05 35K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000239_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000239_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000282_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000282_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000283_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000283_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000479_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000479_fr.xml 2026-02-07 12:05 38K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000630_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000630_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000815_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000815_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000528_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000529_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000530_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000531_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000528_fr.xml 2026-02-07 12:06 38K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000529_fr.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000530_fr.xml 2026-02-07 12:07 38K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000531_fr.xml 2026-02-07 12:07 38K +[   ] 20260207T120612.072Z_MSC_CitypageWeather_s0000479_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120612.109Z_MSC_CitypageWeather_s0000479_fr.xml 2026-02-07 12:07 38K +[   ] 20260207T120622.544Z_MSC_CitypageWeather_s0000630_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120622.567Z_MSC_CitypageWeather_s0000630_fr.xml 2026-02-07 12:07 37K +[   ] 20260207T120622.795Z_MSC_CitypageWeather_s0000782_en.xml 2026-02-07 12:07 36K +[   ] 20260207T120622.821Z_MSC_CitypageWeather_s0000782_fr.xml 2026-02-07 12:07 37K +[   ] 20260207T120723.672Z_MSC_CitypageWeather_s0000588_en.xml 2026-02-07 12:08 36K +[   ] 20260207T120723.699Z_MSC_CitypageWeather_s0000588_fr.xml 2026-02-07 12:08 36K +[   ] 20260207T120740.860Z_MSC_CitypageWeather_s0000691_en.xml 2026-02-07 12:08 38K +[   ] 20260207T120740.860Z_MSC_CitypageWeather_s0000692_en.xml 2026-02-07 12:08 38K +[   ] 20260207T120740.911Z_MSC_CitypageWeather_s0000691_fr.xml 2026-02-07 12:08 39K +[   ] 20260207T120740.911Z_MSC_CitypageWeather_s0000692_fr.xml 2026-02-07 12:08 39K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000458_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000658_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000786_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000787_en.xml 2026-02-07 12:15 38K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000789_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000458_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000658_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000786_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000787_fr.xml 2026-02-07 12:15 39K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000789_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T122801.995Z_MSC_CitypageWeather_s0000430_en.xml 2026-02-07 12:28 37K +[   ] 20260207T122801.995Z_MSC_CitypageWeather_s0000623_en.xml 2026-02-07 12:28 37K +[   ] 20260207T122802.053Z_MSC_CitypageWeather_s0000430_fr.xml 2026-02-07 12:28 38K +[   ] 20260207T122802.053Z_MSC_CitypageWeather_s0000623_fr.xml 2026-02-07 12:28 38K +[   ] 20260207T124745.464Z_MSC_CitypageWeather_s0000752_en.xml 2026-02-07 12:47 36K +[   ] 20260207T124745.464Z_MSC_CitypageWeather_s0000753_en.xml 2026-02-07 12:47 36K +[   ] 20260207T124745.526Z_MSC_CitypageWeather_s0000752_fr.xml 2026-02-07 12:47 37K +[   ] 20260207T124745.526Z_MSC_CitypageWeather_s0000753_fr.xml 2026-02-07 12:47 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000458_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000658_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000786_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000787_en.xml 2026-02-07 12:48 38K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000789_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000458_fr.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000658_fr.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000786_fr.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000787_fr.xml 2026-02-07 12:48 39K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000789_fr.xml 2026-02-07 12:48 37K +
+ + diff --git a/tests/mocks/weather_onecall_current.json b/tests/mocks/weather_onecall_current.json new file mode 100644 index 0000000000..73b88d7a11 --- /dev/null +++ b/tests/mocks/weather_onecall_current.json @@ -0,0 +1,28 @@ +{ + "lat": 48.14, + "lon": 11.58, + "timezone": "Europe/Berlin", + "timezone_offset": 0, + "current": { + "dt": 1547387400, + "sunrise": 1547362817, + "sunset": 1547394301, + "temp": 1.49, + "feels_like": -5.6, + "pressure": 1005, + "humidity": 93.7, + "uvi": 0, + "clouds": 75, + "visibility": 7000, + "wind_speed": 11.8, + "wind_deg": 250, + "weather": [ + { + "id": 615, + "main": "Snow", + "description": "light rain and snow", + "icon": "13d" + } + ] + } +} diff --git a/tests/mocks/weather_onecall_forecast.json b/tests/mocks/weather_onecall_forecast.json new file mode 100644 index 0000000000..aa70f6e597 --- /dev/null +++ b/tests/mocks/weather_onecall_forecast.json @@ -0,0 +1,149 @@ +{ + "lat": 48.14, + "lon": 11.58, + "timezone": "Europe/Berlin", + "timezone_offset": 3600, + "daily": [ + { + "dt": 1568372400, + "sunrise": 1568350044, + "sunset": 1568395948, + "temp": { + "day": 24.44, + "min": 15.35, + "max": 24.44, + "night": 15.35, + "eve": 18, + "morn": 23.03 + }, + "pressure": 1031, + "humidity": 70, + "wind_speed": 3.35, + "wind_deg": 314, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ], + "clouds": 21, + "pop": 0, + "uvi": 5 + }, + { + "dt": 1568458800, + "sunrise": 1568436525, + "sunset": 1568482223, + "temp": { + "day": 20.81, + "min": 13.56, + "max": 21.02, + "night": 13.56, + "eve": 16.6, + "morn": 15.88 + }, + "pressure": 1028, + "humidity": 72, + "wind_speed": 2.78, + "wind_deg": 266, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "clouds": 21, + "pop": 0.56, + "rain": 2.51, + "uvi": 4.5 + }, + { + "dt": 1568545200, + "sunrise": 1568523006, + "sunset": 1568568498, + "temp": { + "day": 22.93, + "min": 13.78, + "max": 22.93, + "night": 13.78, + "eve": 17.21, + "morn": 14.56 + }, + "pressure": 1024, + "humidity": 59, + "wind_speed": 2.17, + "wind_deg": 255, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 5.2 + }, + { + "dt": 1568631600, + "sunrise": 1568609487, + "sunset": 1568654774, + "temp": { + "day": 23.39, + "min": 13.93, + "max": 23.39, + "night": 13.93, + "eve": 17.98, + "morn": 15.05 + }, + "pressure": 1023, + "humidity": 57, + "wind_speed": 1.93, + "wind_deg": 236, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 5.1 + }, + { + "dt": 1568718000, + "sunrise": 1568695968, + "sunset": 1568741049, + "temp": { + "day": 20.64, + "min": 10.87, + "max": 20.64, + "night": 10.87, + "eve": 15.21, + "morn": 13.67 + }, + "pressure": 1021, + "humidity": 64, + "wind_speed": 2.44, + "wind_deg": 284, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 3, + "pop": 0, + "uvi": 4.9 + } + ] +} diff --git a/tests/mocks/weather_hourly.json b/tests/mocks/weather_onecall_hourly.json similarity index 99% rename from tests/mocks/weather_hourly.json rename to tests/mocks/weather_onecall_hourly.json index b0b2e66245..bcf2b806f6 100644 --- a/tests/mocks/weather_hourly.json +++ b/tests/mocks/weather_onecall_hourly.json @@ -1,7 +1,11 @@ { + "lat": 48.14, + "lon": 11.58, + "timezone": "Europe/Berlin", + "timezone_offset": 3600, "hourly": [ { - "dt": 1673204400, + "dt": 1673200800, "temp": 27.31, "feels_like": 29.59, "pressure": 1013, @@ -24,7 +28,7 @@ "pop": 0 }, { - "dt": 1673208000, + "dt": 1673204400, "temp": 27.31, "feels_like": 29.69, "pressure": 1013, @@ -47,7 +51,7 @@ "pop": 0 }, { - "dt": 1673211600, + "dt": 1673208000, "temp": 27.29, "feels_like": 29.65, "pressure": 1013, @@ -70,7 +74,7 @@ "pop": 0.12 }, { - "dt": 1673215200, + "dt": 1673211600, "temp": 27.21, "feels_like": 29.6, "pressure": 1013, @@ -96,7 +100,7 @@ } }, { - "dt": 1673218800, + "dt": 1673215200, "temp": 27.1, "feels_like": 29.39, "pressure": 1014, @@ -122,7 +126,7 @@ } }, { - "dt": 1673222400, + "dt": 1673218800, "temp": 26.95, "feels_like": 29.19, "pressure": 1013, @@ -145,7 +149,7 @@ "pop": 0.52 }, { - "dt": 1673226000, + "dt": 1673222400, "temp": 26.72, "feels_like": 28.83, "pressure": 1012, @@ -168,7 +172,7 @@ "pop": 0.08 }, { - "dt": 1673229600, + "dt": 1673226000, "temp": 26.57, "feels_like": 26.57, "pressure": 1012, @@ -191,7 +195,7 @@ "pop": 0.08 }, { - "dt": 1673233200, + "dt": 1673229600, "temp": 26.46, "feels_like": 26.46, "pressure": 1011, @@ -214,7 +218,7 @@ "pop": 0.04 }, { - "dt": 1673236800, + "dt": 1673233200, "temp": 26.38, "feels_like": 26.38, "pressure": 1011, @@ -237,7 +241,7 @@ "pop": 0 }, { - "dt": 1673240400, + "dt": 1673236800, "temp": 26.32, "feels_like": 26.32, "pressure": 1012, @@ -260,7 +264,7 @@ "pop": 0 }, { - "dt": 1673244000, + "dt": 1673240400, "temp": 26.32, "feels_like": 26.32, "pressure": 1012, @@ -283,7 +287,7 @@ "pop": 0 }, { - "dt": 1673247600, + "dt": 1673244000, "temp": 26.44, "feels_like": 26.44, "pressure": 1013, @@ -306,7 +310,7 @@ "pop": 0 }, { - "dt": 1673251200, + "dt": 1673247600, "temp": 26.45, "feels_like": 26.45, "pressure": 1013, @@ -329,7 +333,7 @@ "pop": 0 }, { - "dt": 1673254800, + "dt": 1673251200, "temp": 26.54, "feels_like": 26.54, "pressure": 1014, @@ -352,7 +356,7 @@ "pop": 0 }, { - "dt": 1673258400, + "dt": 1673254800, "temp": 26.61, "feels_like": 26.61, "pressure": 1013, @@ -375,7 +379,7 @@ "pop": 0 }, { - "dt": 1673262000, + "dt": 1673258400, "temp": 26.76, "feels_like": 28.9, "pressure": 1013, @@ -398,7 +402,7 @@ "pop": 0 }, { - "dt": 1673265600, + "dt": 1673262000, "temp": 26.91, "feels_like": 29.11, "pressure": 1012, @@ -421,7 +425,7 @@ "pop": 0 }, { - "dt": 1673269200, + "dt": 1673265600, "temp": 27.04, "feels_like": 29.27, "pressure": 1011, @@ -444,7 +448,7 @@ "pop": 0 }, { - "dt": 1673272800, + "dt": 1673269200, "temp": 27.12, "feels_like": 29.33, "pressure": 1011, @@ -467,7 +471,7 @@ "pop": 0 }, { - "dt": 1673276400, + "dt": 1673272800, "temp": 27.17, "feels_like": 29.33, "pressure": 1010, @@ -490,7 +494,7 @@ "pop": 0 }, { - "dt": 1673280000, + "dt": 1673276400, "temp": 27.28, "feels_like": 29.43, "pressure": 1011, @@ -513,7 +517,7 @@ "pop": 0 }, { - "dt": 1673283600, + "dt": 1673280000, "temp": 27.28, "feels_like": 29.43, "pressure": 1011, @@ -536,7 +540,7 @@ "pop": 0 }, { - "dt": 1673287200, + "dt": 1673283600, "temp": 27.34, "feels_like": 29.54, "pressure": 1012, @@ -559,7 +563,7 @@ "pop": 0 }, { - "dt": 1673290800, + "dt": 1673287200, "temp": 27.25, "feels_like": 29.38, "pressure": 1013, @@ -582,7 +586,7 @@ "pop": 0 }, { - "dt": 1673294400, + "dt": 1673290800, "temp": 27.25, "feels_like": 29.38, "pressure": 1014, @@ -605,7 +609,7 @@ "pop": 0 }, { - "dt": 1673298000, + "dt": 1673294400, "temp": 27.17, "feels_like": 29.24, "pressure": 1015, @@ -628,7 +632,7 @@ "pop": 0 }, { - "dt": 1673301600, + "dt": 1673298000, "temp": 27.07, "feels_like": 29.06, "pressure": 1015, @@ -651,7 +655,7 @@ "pop": 0 }, { - "dt": 1673305200, + "dt": 1673301600, "temp": 26.99, "feels_like": 29.09, "pressure": 1014, @@ -674,7 +678,7 @@ "pop": 0 }, { - "dt": 1673308800, + "dt": 1673305200, "temp": 26.83, "feels_like": 28.8, "pressure": 1014, @@ -697,7 +701,7 @@ "pop": 0 }, { - "dt": 1673312400, + "dt": 1673308800, "temp": 26.68, "feels_like": 28.54, "pressure": 1013, @@ -720,7 +724,7 @@ "pop": 0 }, { - "dt": 1673316000, + "dt": 1673312400, "temp": 26.54, "feels_like": 26.54, "pressure": 1013, @@ -743,7 +747,7 @@ "pop": 0 }, { - "dt": 1673319600, + "dt": 1673316000, "temp": 26.54, "feels_like": 26.54, "pressure": 1012, @@ -766,7 +770,7 @@ "pop": 0 }, { - "dt": 1673323200, + "dt": 1673319600, "temp": 26.43, "feels_like": 26.43, "pressure": 1012, @@ -789,7 +793,7 @@ "pop": 0 }, { - "dt": 1673326800, + "dt": 1673323200, "temp": 26.38, "feels_like": 26.38, "pressure": 1013, @@ -812,7 +816,7 @@ "pop": 0 }, { - "dt": 1673330400, + "dt": 1673326800, "temp": 26.36, "feels_like": 26.36, "pressure": 1013, @@ -835,7 +839,7 @@ "pop": 0 }, { - "dt": 1673334000, + "dt": 1673330400, "temp": 26.45, "feels_like": 26.45, "pressure": 1014, @@ -858,7 +862,7 @@ "pop": 0 }, { - "dt": 1673337600, + "dt": 1673334000, "temp": 26.54, "feels_like": 26.54, "pressure": 1014, @@ -881,7 +885,7 @@ "pop": 0 }, { - "dt": 1673341200, + "dt": 1673337600, "temp": 26.63, "feels_like": 26.63, "pressure": 1014, @@ -904,7 +908,7 @@ "pop": 0 }, { - "dt": 1673344800, + "dt": 1673341200, "temp": 26.62, "feels_like": 26.62, "pressure": 1014, @@ -927,7 +931,7 @@ "pop": 0 }, { - "dt": 1673348400, + "dt": 1673344800, "temp": 26.71, "feels_like": 28.81, "pressure": 1014, @@ -950,7 +954,7 @@ "pop": 0 }, { - "dt": 1673352000, + "dt": 1673348400, "temp": 26.81, "feels_like": 29, "pressure": 1013, @@ -973,7 +977,7 @@ "pop": 0 }, { - "dt": 1673355600, + "dt": 1673352000, "temp": 26.91, "feels_like": 29.19, "pressure": 1012, @@ -996,7 +1000,7 @@ "pop": 0 }, { - "dt": 1673359200, + "dt": 1673355600, "temp": 27.02, "feels_like": 29.32, "pressure": 1012, @@ -1019,7 +1023,7 @@ "pop": 0 }, { - "dt": 1673362800, + "dt": 1673359200, "temp": 27.03, "feels_like": 29.25, "pressure": 1011, @@ -1042,7 +1046,7 @@ "pop": 0 }, { - "dt": 1673366400, + "dt": 1673362800, "temp": 27.12, "feels_like": 29.42, "pressure": 1011, @@ -1065,7 +1069,7 @@ "pop": 0 }, { - "dt": 1673370000, + "dt": 1673366400, "temp": 27.1, "feels_like": 29.29, "pressure": 1012, @@ -1088,7 +1092,7 @@ "pop": 0 }, { - "dt": 1673373600, + "dt": 1673370000, "temp": 27.18, "feels_like": 29.54, "pressure": 1012, diff --git a/tests/mocks/weather_openmeteo_current.json b/tests/mocks/weather_openmeteo_current.json new file mode 100644 index 0000000000..478ee1d161 --- /dev/null +++ b/tests/mocks/weather_openmeteo_current.json @@ -0,0 +1,218 @@ +{ + "latitude": 40.78858, + "longitude": -73.9661, + "generationtime_ms": 0.7585287094116211, + "utc_offset_seconds": -18000, + "timezone": "America/New_York", + "timezone_abbreviation": "GMT-5", + "elevation": 20.0, + "current_units": { "time": "iso8601", "interval": "seconds", "temperature_2m": "°C", "relative_humidity_2m": "%", "weather_code": "wmo code", "wind_speed_10m": "km/h", "wind_direction_10m": "°" }, + "current": { "time": "2026-02-06T16:30", "interval": 900, "temperature_2m": -1.4, "relative_humidity_2m": 60, "weather_code": 3, "wind_speed_10m": 4.8, "wind_direction_10m": 138 }, + "hourly_units": { "time": "iso8601", "temperature_2m": "°C", "precipitation": "mm", "weather_code": "wmo code", "wind_speed_10m": "km/h" }, + "hourly": { + "time": [ + "2026-02-06T00:00", + "2026-02-06T01:00", + "2026-02-06T02:00", + "2026-02-06T03:00", + "2026-02-06T04:00", + "2026-02-06T05:00", + "2026-02-06T06:00", + "2026-02-06T07:00", + "2026-02-06T08:00", + "2026-02-06T09:00", + "2026-02-06T10:00", + "2026-02-06T11:00", + "2026-02-06T12:00", + "2026-02-06T13:00", + "2026-02-06T14:00", + "2026-02-06T15:00", + "2026-02-06T16:00", + "2026-02-06T17:00", + "2026-02-06T18:00", + "2026-02-06T19:00", + "2026-02-06T20:00", + "2026-02-06T21:00", + "2026-02-06T22:00", + "2026-02-06T23:00", + "2026-02-07T00:00", + "2026-02-07T01:00", + "2026-02-07T02:00", + "2026-02-07T03:00", + "2026-02-07T04:00", + "2026-02-07T05:00", + "2026-02-07T06:00", + "2026-02-07T07:00", + "2026-02-07T08:00", + "2026-02-07T09:00", + "2026-02-07T10:00", + "2026-02-07T11:00", + "2026-02-07T12:00", + "2026-02-07T13:00", + "2026-02-07T14:00", + "2026-02-07T15:00", + "2026-02-07T16:00", + "2026-02-07T17:00", + "2026-02-07T18:00", + "2026-02-07T19:00", + "2026-02-07T20:00", + "2026-02-07T21:00", + "2026-02-07T22:00", + "2026-02-07T23:00", + "2026-02-08T00:00", + "2026-02-08T01:00", + "2026-02-08T02:00", + "2026-02-08T03:00", + "2026-02-08T04:00", + "2026-02-08T05:00", + "2026-02-08T06:00", + "2026-02-08T07:00", + "2026-02-08T08:00", + "2026-02-08T09:00", + "2026-02-08T10:00", + "2026-02-08T11:00", + "2026-02-08T12:00", + "2026-02-08T13:00", + "2026-02-08T14:00", + "2026-02-08T15:00", + "2026-02-08T16:00", + "2026-02-08T17:00", + "2026-02-08T18:00", + "2026-02-08T19:00", + "2026-02-08T20:00", + "2026-02-08T21:00", + "2026-02-08T22:00", + "2026-02-08T23:00", + "2026-02-09T00:00", + "2026-02-09T01:00", + "2026-02-09T02:00", + "2026-02-09T03:00", + "2026-02-09T04:00", + "2026-02-09T05:00", + "2026-02-09T06:00", + "2026-02-09T07:00", + "2026-02-09T08:00", + "2026-02-09T09:00", + "2026-02-09T10:00", + "2026-02-09T11:00", + "2026-02-09T12:00", + "2026-02-09T13:00", + "2026-02-09T14:00", + "2026-02-09T15:00", + "2026-02-09T16:00", + "2026-02-09T17:00", + "2026-02-09T18:00", + "2026-02-09T19:00", + "2026-02-09T20:00", + "2026-02-09T21:00", + "2026-02-09T22:00", + "2026-02-09T23:00", + "2026-02-10T00:00", + "2026-02-10T01:00", + "2026-02-10T02:00", + "2026-02-10T03:00", + "2026-02-10T04:00", + "2026-02-10T05:00", + "2026-02-10T06:00", + "2026-02-10T07:00", + "2026-02-10T08:00", + "2026-02-10T09:00", + "2026-02-10T10:00", + "2026-02-10T11:00", + "2026-02-10T12:00", + "2026-02-10T13:00", + "2026-02-10T14:00", + "2026-02-10T15:00", + "2026-02-10T16:00", + "2026-02-10T17:00", + "2026-02-10T18:00", + "2026-02-10T19:00", + "2026-02-10T20:00", + "2026-02-10T21:00", + "2026-02-10T22:00", + "2026-02-10T23:00", + "2026-02-11T00:00", + "2026-02-11T01:00", + "2026-02-11T02:00", + "2026-02-11T03:00", + "2026-02-11T04:00", + "2026-02-11T05:00", + "2026-02-11T06:00", + "2026-02-11T07:00", + "2026-02-11T08:00", + "2026-02-11T09:00", + "2026-02-11T10:00", + "2026-02-11T11:00", + "2026-02-11T12:00", + "2026-02-11T13:00", + "2026-02-11T14:00", + "2026-02-11T15:00", + "2026-02-11T16:00", + "2026-02-11T17:00", + "2026-02-11T18:00", + "2026-02-11T19:00", + "2026-02-11T20:00", + "2026-02-11T21:00", + "2026-02-11T22:00", + "2026-02-11T23:00", + "2026-02-12T00:00", + "2026-02-12T01:00", + "2026-02-12T02:00", + "2026-02-12T03:00", + "2026-02-12T04:00", + "2026-02-12T05:00", + "2026-02-12T06:00", + "2026-02-12T07:00", + "2026-02-12T08:00", + "2026-02-12T09:00", + "2026-02-12T10:00", + "2026-02-12T11:00", + "2026-02-12T12:00", + "2026-02-12T13:00", + "2026-02-12T14:00", + "2026-02-12T15:00", + "2026-02-12T16:00", + "2026-02-12T17:00", + "2026-02-12T18:00", + "2026-02-12T19:00", + "2026-02-12T20:00", + "2026-02-12T21:00", + "2026-02-12T22:00", + "2026-02-12T23:00" + ], + "temperature_2m": [ + -7.1, -7.1, -8.5, -8.8, -9.0, -7.7, -9.2, -8.8, -8.9, -5.9, -3.4, -2.4, -1.3, -0.8, -0.5, -0.2, -0.7, -2.1, -3.2, -3.7, -4.4, -5.3, -6.2, -6.8, -6.5, -6.3, -6.5, -6.3, -5.8, -5.3, -8.5, -11.2, -12.7, -13.6, -13.9, -13.8, -13.5, -13.2, + -12.9, -13.0, -13.2, -14.0, -15.2, -16.1, -16.9, -17.4, -17.5, -17.7, -18.1, -18.5, -18.9, -19.4, -20.0, -20.5, -21.0, -21.5, -20.2, -18.3, -16.4, -14.2, -12.5, -10.8, -9.3, -8.9, -10.0, -10.6, -11.2, -11.8, -12.5, -12.9, -13.4, -13.9, + -14.9, -15.7, -16.6, -17.0, -17.3, -17.5, -17.7, -18.1, -15.5, -12.6, -10.5, -8.6, -7.1, -5.8, -4.9, -4.4, -4.1, -4.9, -7.1, -9.1, -9.7, -6.9, -6.5, -6.4, -6.1, -7.2, -9.6, -10.1, -10.5, -10.8, -10.8, -11.0, -9.1, -6.7, -4.8, -3.1, -2.3, + -1.7, -1.2, -0.8, -0.7, -1.1, -1.8, -1.7, -1.7, -1.9, -4.7, -5.3, -5.3, -5.1, -5.1, -4.8, -5.0, -1.9, -1.2, -0.3, 0.4, 0.6, 0.8, 0.8, 0.6, 0.5, 0.5, 0.6, 0.8, 1.3, 1.9, 2.0, 1.3, 0.1, -0.9, -1.3, -1.6, -1.8, -2.0, -2.3, -2.6, -3.2, -3.8, + -3.9, -3.1, -1.8, -0.8, -0.3, -0.1, 0.0, 0.0, -0.1, -0.5, -1.3, -2.4, -3.3, -4.0, -4.5, -5.0, -5.3 + ], + "precipitation": [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1, 0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.4, 1.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.3, 0.3, 0.3, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ], + "weather_code": [ + 0, 0, 0, 0, 2, 0, 3, 3, 3, 0, 3, 3, 3, 1, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 71, 71, 3, 3, 3, 3, 3, 51, 3, 3, 3, 3, 3, 3, 3, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 1, 1, 1, 1, 2, 2, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 56, 71, 3, 3, 45, 45, 45, 45, 51, 51, 51, 3, 3, 3, 3, 3, 3, 3, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2 + ], + "wind_speed_10m": [ + 4.3, 9.3, 8.1, 3.3, 11.3, 8.2, 8.8, 7.2, 3.6, 7.2, 5.4, 4.7, 5.0, 1.5, 1.1, 3.9, 4.9, 5.8, 6.5, 5.8, 7.1, 9.3, 8.5, 6.6, 6.6, 6.9, 6.9, 7.5, 8.9, 12.0, 25.8, 27.3, 27.3, 27.0, 28.5, 30.5, 30.8, 30.0, 29.3, 28.3, 27.8, 27.5, 26.3, 24.5, + 25.7, 22.9, 21.9, 19.4, 18.7, 18.4, 21.0, 19.0, 19.1, 18.5, 17.0, 13.8, 13.3, 18.1, 18.6, 19.7, 20.4, 20.5, 20.9, 21.7, 24.2, 22.2, 20.2, 18.8, 15.9, 13.8, 12.8, 11.2, 7.9, 5.9, 4.9, 4.6, 4.3, 3.2, 2.9, 3.0, 5.2, 6.9, 7.5, 6.6, 6.6, 5.2, + 4.1, 5.3, 2.2, 1.9, 2.6, 1.1, 2.2, 4.0, 3.9, 5.2, 4.0, 2.2, 5.0, 4.4, 4.5, 4.7, 4.2, 5.8, 5.8, 7.9, 6.8, 6.0, 5.4, 4.2, 3.4, 3.5, 1.1, 2.8, 5.3, 5.8, 6.1, 4.9, 4.8, 3.3, 3.7, 2.2, 1.0, 1.4, 4.0, 4.4, 3.7, 6.9, 7.6, 7.3, 6.6, 5.5, 4.1, + 3.9, 4.9, 6.0, 7.3, 9.7, 14.1, 17.1, 16.9, 15.1, 14.0, 14.7, 16.4, 17.4, 18.0, 18.1, 17.6, 15.5, 12.9, 11.9, 13.2, 15.8, 18.2, 19.0, 19.2, 19.2, 19.5, 19.7, 19.7, 19.0, 18.0, 17.4, 17.4, 17.6, 17.9, 18.1 + ] + }, + "daily_units": { "time": "iso8601", "weather_code": "wmo code", "temperature_2m_max": "°C", "temperature_2m_min": "°C", "sunrise": "iso8601", "sunset": "iso8601", "precipitation_sum": "mm" }, + "daily": { + "time": ["2026-02-06", "2026-02-07", "2026-02-08", "2026-02-09", "2026-02-10", "2026-02-11", "2026-02-12"], + "weather_code": [3, 71, 3, 3, 3, 71, 3], + "temperature_2m_max": [-0.2, -5.3, -8.9, -4.1, -0.7, 2.0, 0.0], + "temperature_2m_min": [-9.2, -17.7, -21.5, -18.1, -11.0, -5.3, -5.3], + "sunrise": ["2026-02-06T07:00", "2026-02-07T06:59", "2026-02-08T06:58", "2026-02-09T06:56", "2026-02-10T06:55", "2026-02-11T06:54", "2026-02-12T06:53"], + "sunset": ["2026-02-06T17:19", "2026-02-07T17:20", "2026-02-08T17:21", "2026-02-09T17:23", "2026-02-10T17:24", "2026-02-11T17:25", "2026-02-12T17:26"], + "precipitation_sum": [0.0, 0.4, 0.0, 0.0, 0.0, 2.6, 0.0] + } +} diff --git a/tests/mocks/weather_openmeteo_current_weather.json b/tests/mocks/weather_openmeteo_current_weather.json new file mode 100644 index 0000000000..ba5f183161 --- /dev/null +++ b/tests/mocks/weather_openmeteo_current_weather.json @@ -0,0 +1,84 @@ +{ + "latitude": 48.14, + "longitude": 11.58, + "generationtime_ms": 0.3949403762817383, + "utc_offset_seconds": 3600, + "timezone": "Europe/Berlin", + "timezone_abbreviation": "GMT+1", + "elevation": 524.0, + "current_weather_units": { + "time": "unixtime", + "interval": "seconds", + "temperature": "°C", + "windspeed": "km/h", + "winddirection": "°", + "is_day": "", + "weathercode": "wmo code" + }, + "current_weather": { + "time": 1770477300, + "interval": 900, + "temperature": 8.5, + "windspeed": 4.7, + "winddirection": 9, + "is_day": 1, + "weathercode": 2 + }, + "hourly_units": { + "time": "unixtime", + "temperature_2m": "°C", + "windspeed_10m": "km/h", + "winddirection_10m": "°", + "relativehumidity_2m": "%" + }, + "hourly": { + "time": [ + 1770418800, 1770422400, 1770426000, 1770429600, 1770433200, 1770436800, 1770440400, 1770444000, 1770447600, 1770451200, 1770454800, 1770458400, 1770462000, 1770465600, 1770469200, 1770472800, 1770476400, 1770480000, 1770483600, + 1770487200, 1770490800, 1770494400, 1770498000, 1770501600, 1770505200, 1770508800, 1770512400, 1770516000, 1770519600, 1770523200, 1770526800, 1770530400, 1770534000, 1770537600, 1770541200, 1770544800, 1770548400, 1770552000, + 1770555600, 1770559200, 1770562800, 1770566400, 1770570000, 1770573600, 1770577200, 1770580800, 1770584400, 1770588000, 1770591600, 1770595200, 1770598800, 1770602400, 1770606000, 1770609600, 1770613200, 1770616800, 1770620400, + 1770624000, 1770627600, 1770631200, 1770634800, 1770638400, 1770642000, 1770645600, 1770649200, 1770652800, 1770656400, 1770660000, 1770663600, 1770667200, 1770670800, 1770674400, 1770678000, 1770681600, 1770685200, 1770688800, + 1770692400, 1770696000, 1770699600, 1770703200, 1770706800, 1770710400, 1770714000, 1770717600, 1770721200, 1770724800, 1770728400, 1770732000, 1770735600, 1770739200, 1770742800, 1770746400, 1770750000, 1770753600, 1770757200, + 1770760800, 1770764400, 1770768000, 1770771600, 1770775200, 1770778800, 1770782400, 1770786000, 1770789600, 1770793200, 1770796800, 1770800400, 1770804000, 1770807600, 1770811200, 1770814800, 1770818400, 1770822000, 1770825600, + 1770829200, 1770832800, 1770836400, 1770840000, 1770843600, 1770847200, 1770850800, 1770854400, 1770858000, 1770861600, 1770865200, 1770868800, 1770872400, 1770876000, 1770879600, 1770883200, 1770886800, 1770890400, 1770894000, + 1770897600, 1770901200, 1770904800, 1770908400, 1770912000, 1770915600, 1770919200, 1770922800, 1770926400, 1770930000, 1770933600, 1770937200, 1770940800, 1770944400, 1770948000, 1770951600, 1770955200, 1770958800, 1770962400, + 1770966000, 1770969600, 1770973200, 1770976800, 1770980400, 1770984000, 1770987600, 1770991200, 1770994800, 1770998400, 1771002000, 1771005600, 1771009200, 1771012800, 1771016400, 1771020000 + ], + "temperature_2m": [ + 6.4, 6.6, 6.3, 5.8, 5.7, 5.1, 5.0, 5.5, 5.2, 5.3, 6.2, 7.0, 7.9, 9.1, 9.5, 9.0, 8.6, 8.2, 7.2, 6.4, 5.5, 5.1, 4.7, 4.9, 4.5, 4.5, 4.5, 4.7, 3.8, 3.1, 3.2, 3.0, 3.6, 3.8, 3.5, 4.3, 5.2, 6.2, 6.6, 6.8, 6.4, 5.5, 4.7, 4.7, 4.3, 4.2, 4.1, + 3.9, 3.6, 3.4, 3.2, 3.0, 2.9, 2.8, 2.7, 2.6, 2.5, 2.8, 3.1, 3.7, 4.3, 4.6, 4.7, 4.7, 4.4, 4.0, 3.5, 3.0, 2.5, 1.9, 1.2, 0.7, 0.3, 0.0, -0.1, -0.4, -0.8, -1.1, -1.3, -1.2, -1.2, -1.0, 0.2, 1.9, 3.3, 5.0, 6.1, 6.8, 7.0, 6.4, 5.2, 4.2, 3.6, + 3.1, 2.8, 2.5, 2.2, 2.2, 2.3, 2.6, 2.9, 3.0, 3.0, 3.4, 4.7, 6.3, 7.7, 8.5, 9.0, 9.3, 9.2, 8.9, 8.6, 8.3, 8.1, 7.9, 7.6, 7.4, 7.2, 7.1, 7.0, 6.9, 6.7, 6.4, 6.2, 5.6, 5.2, 4.9, 6.1, 6.4, 6.6, 6.9, 7.1, 7.2, 7.2, 7.0, 6.8, 6.4, 5.9, 5.5, + 5.4, 5.3, 5.2, 5.0, 4.8, 4.6, 4.5, 4.5, 4.6, 4.6, 4.6, 4.8, 5.2, 5.8, 6.4, 7.2, 8.1, 8.6, 8.3, 7.6, 7.0, 6.5, 6.2, 5.8, 5.5, 5.2, 5.0, 4.9 + ], + "windspeed_10m": [ + 9.4, 9.5, 9.2, 9.1, 7.9, 6.6, 6.8, 7.6, 6.7, 6.0, 5.9, 5.9, 6.6, 4.5, 7.9, 7.7, 5.4, 4.2, 3.3, 1.4, 2.6, 1.6, 2.2, 1.5, 0.8, 2.3, 2.3, 1.3, 2.5, 1.8, 1.6, 1.1, 1.8, 2.9, 5.6, 9.1, 9.0, 7.8, 9.0, 9.8, 9.7, 9.8, 10.4, 9.1, 7.9, 8.2, 7.6, + 6.0, 6.2, 5.3, 5.0, 5.5, 6.2, 5.9, 6.2, 6.5, 5.9, 5.4, 6.2, 5.9, 6.7, 6.5, 6.2, 6.2, 5.8, 5.5, 4.7, 4.2, 3.2, 3.4, 3.2, 2.7, 2.9, 3.7, 2.9, 3.4, 3.2, 3.6, 3.1, 3.6, 3.8, 4.6, 4.8, 4.3, 5.5, 5.3, 5.2, 4.9, 4.4, 3.2, 2.2, 2.0, 2.2, 3.0, + 3.4, 3.6, 4.0, 4.9, 5.2, 5.2, 5.2, 5.5, 5.8, 6.5, 9.0, 13.2, 16.9, 19.3, 20.8, 21.7, 22.3, 22.1, 22.0, 21.5, 21.2, 20.9, 20.2, 20.0, 19.3, 18.9, 18.2, 17.4, 16.4, 15.3, 14.2, 12.8, 11.7, 11.2, 21.7, 21.5, 21.5, 22.1, 23.0, 23.7, 23.8, + 23.6, 23.0, 22.0, 20.5, 19.5, 19.2, 19.3, 19.3, 19.5, 19.5, 20.0, 20.9, 21.9, 23.2, 24.7, 26.1, 26.9, 26.3, 25.4, 24.6, 25.0, 25.6, 26.1, 25.5, 24.4, 22.9, 20.5, 17.9, 15.9, 14.8, 14.3, 14.2, 14.8 + ], + "winddirection_10m": [ + 247, 241, 244, 252, 240, 229, 245, 251, 234, 245, 256, 256, 261, 284, 300, 332, 356, 59, 84, 90, 164, 207, 189, 284, 243, 198, 321, 304, 172, 180, 207, 180, 79, 30, 45, 72, 74, 68, 74, 73, 75, 54, 56, 56, 66, 67, 82, 57, 69, 62, 60, 58, + 69, 79, 83, 87, 101, 98, 97, 95, 88, 89, 97, 97, 97, 113, 122, 121, 117, 108, 117, 113, 120, 119, 120, 122, 117, 127, 135, 135, 131, 129, 117, 95, 79, 62, 56, 54, 55, 63, 90, 135, 171, 194, 198, 180, 153, 144, 146, 155, 164, 169, 173, + 186, 209, 225, 232, 237, 242, 246, 247, 248, 249, 249, 249, 249, 248, 247, 246, 246, 245, 246, 244, 240, 240, 240, 242, 245, 249, 249, 249, 251, 253, 253, 252, 251, 250, 249, 246, 245, 244, 243, 243, 242, 242, 242, 243, 245, 246, 247, + 247, 247, 247, 245, 245, 247, 250, 252, 254, 254, 254, 252, 248, 245, 244, 245, 246, 247 + ], + "relativehumidity_2m": [ + 83, 81, 81, 84, 83, 85, 86, 88, 89, 89, 85, 82, 77, 65, 62, 68, 71, 73, 81, 87, 91, 91, 93, 93, 94, 94, 94, 94, 99, 97, 94, 93, 94, 95, 96, 91, 86, 79, 77, 77, 76, 81, 85, 82, 89, 88, 87, 87, 89, 90, 90, 91, 92, 93, 94, 94, 95, 93, 89, + 83, 80, 75, 75, 75, 76, 79, 82, 83, 86, 90, 93, 93, 93, 95, 95, 95, 95, 97, 98, 99, 99, 98, 92, 84, 79, 73, 69, 67, 67, 69, 73, 77, 80, 83, 85, 87, 88, 87, 83, 76, 73, 76, 81, 85, 84, 82, 79, 76, 74, 72, 72, 72, 73, 73, 73, 74, 75, 77, + 78, 77, 75, 74, 73, 73, 74, 78, 84, 87, 81, 80, 78, 77, 77, 77, 77, 77, 78, 80, 82, 84, 85, 86, 86, 86, 87, 87, 87, 88, 88, 88, 88, 86, 82, 78, 73, 69, 65, 63, 64, 68, 72, 76, 81, 84, 85, 85, 85, 85 + ] + }, + "daily_units": { + "time": "unixtime", + "temperature_2m_max": "°C", + "temperature_2m_min": "°C", + "sunrise": "unixtime", + "sunset": "unixtime" + }, + "daily": { + "time": [1770418800, 1770505200, 1770591600, 1770678000, 1770764400, 1770850800, 1770937200], + "temperature_2m_max": [9.5, 6.8, 4.7, 7.0, 9.3, 7.2, 8.6], + "temperature_2m_min": [4.7, 3.0, 0.7, -1.3, 2.2, 4.9, 4.5], + "sunrise": [1770445978, 1770532288, 1770618596, 1770704904, 1770791211, 1770877515, 1770963818], + "sunset": [1770481348, 1770567844, 1770654340, 1770740836, 1770827331, 1770913826, 1771000321] + } +} diff --git a/tests/mocks/weather_owm_onecall.json b/tests/mocks/weather_owm_onecall.json new file mode 100644 index 0000000000..0696e347cb --- /dev/null +++ b/tests/mocks/weather_owm_onecall.json @@ -0,0 +1,970 @@ +{ + "lat": 40.7767, + "lon": -73.9713, + "timezone": "America/New_York", + "timezone_offset": -18000, + "current": { + "dt": 1770414297, + "sunrise": 1770379257, + "sunset": 1770416341, + "temp": -0.27, + "feels_like": -3.9, + "pressure": 1004, + "humidity": 54, + "dew_point": -7.54, + "uvi": 0, + "clouds": 75, + "visibility": 10000, + "wind_speed": 3.09, + "wind_deg": 220, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }] + }, + "hourly": [ + { + "dt": 1770411600, + "temp": -0.66, + "feels_like": -3.52, + "pressure": 1004, + "humidity": 61, + "dew_point": -6.5, + "uvi": 0.18, + "clouds": 80, + "visibility": 10000, + "wind_speed": 2.24, + "wind_deg": 187, + "wind_gust": 3.73, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770415200, + "temp": -0.27, + "feels_like": -2.6, + "pressure": 1004, + "humidity": 54, + "dew_point": -7.54, + "uvi": 0, + "clouds": 75, + "visibility": 10000, + "wind_speed": 1.87, + "wind_deg": 169, + "wind_gust": 3.26, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770418800, + "temp": -1.03, + "feels_like": -3.4, + "pressure": 1004, + "humidity": 62, + "dew_point": -6.67, + "uvi": 0, + "clouds": 80, + "visibility": 10000, + "wind_speed": 1.81, + "wind_deg": 190, + "wind_gust": 3.93, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770422400, + "temp": -1.54, + "feels_like": -5.39, + "pressure": 1004, + "humidity": 71, + "dew_point": -5.59, + "uvi": 0, + "clouds": 85, + "wind_speed": 3.04, + "wind_deg": 232, + "wind_gust": 6.25, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 0.2, + "snow": { "1h": 0.13 } + }, + { + "dt": 1770426000, + "temp": -2.25, + "feels_like": -5.2, + "pressure": 1004, + "humidity": 80, + "dew_point": -4.89, + "uvi": 0, + "clouds": 90, + "visibility": 235, + "wind_speed": 2.09, + "wind_deg": 224, + "wind_gust": 6.04, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.18 } + }, + { + "dt": 1770429600, + "temp": -2.79, + "feels_like": -6.29, + "pressure": 1003, + "humidity": 89, + "dew_point": -4.17, + "uvi": 0, + "clouds": 95, + "visibility": 177, + "wind_speed": 2.47, + "wind_deg": 217, + "wind_gust": 6.99, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.19 } + }, + { + "dt": 1770433200, + "temp": -3.46, + "feels_like": -7.71, + "pressure": 1002, + "humidity": 96, + "dew_point": -4.21, + "uvi": 0, + "clouds": 100, + "visibility": 501, + "wind_speed": 3.05, + "wind_deg": 236, + "wind_gust": 7.82, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.19 } + }, + { + "dt": 1770436800, + "temp": -3.88, + "feels_like": -7.67, + "pressure": 1001, + "humidity": 97, + "dew_point": -4.47, + "uvi": 0, + "clouds": 100, + "visibility": 424, + "wind_speed": 2.54, + "wind_deg": 234, + "wind_gust": 7.49, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0.8 + }, + { + "dt": 1770440400, + "temp": -3.78, + "feels_like": -7.68, + "pressure": 1001, + "humidity": 96, + "dew_point": -4.57, + "uvi": 0, + "clouds": 100, + "visibility": 2576, + "wind_speed": 2.66, + "wind_deg": 231, + "wind_gust": 7.51, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.14 } + }, + { + "dt": 1770444000, + "temp": -4.1, + "feels_like": -8.05, + "pressure": 1000, + "humidity": 96, + "dew_point": -4.92, + "uvi": 0, + "clouds": 100, + "visibility": 305, + "wind_speed": 2.65, + "wind_deg": 237, + "wind_gust": 7.6, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0.8 + }, + { + "dt": 1770447600, + "temp": -4.12, + "feels_like": -8.44, + "pressure": 1000, + "humidity": 95, + "dew_point": -4.97, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.99, + "wind_deg": 247, + "wind_gust": 7.23, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770451200, + "temp": -4.9, + "feels_like": -9.33, + "pressure": 999, + "humidity": 95, + "dew_point": -5.82, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.95, + "wind_deg": 256, + "wind_gust": 7.85, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770454800, + "temp": -4.84, + "feels_like": -9.36, + "pressure": 999, + "humidity": 94, + "dew_point": -5.93, + "uvi": 0, + "clouds": 100, + "visibility": 4481, + "wind_speed": 3.04, + "wind_deg": 273, + "wind_gust": 10.32, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770458400, + "temp": -5.46, + "feels_like": -12.46, + "pressure": 1000, + "humidity": 85, + "dew_point": -7.96, + "uvi": 0, + "clouds": 100, + "visibility": 9905, + "wind_speed": 7.66, + "wind_deg": 316, + "wind_gust": 11.92, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770462000, + "temp": -9.55, + "feels_like": -16.55, + "pressure": 1001, + "humidity": 76, + "dew_point": -13.6, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 8.25, + "wind_deg": 315, + "wind_gust": 15.03, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770465600, + "temp": -12.37, + "feels_like": -19.37, + "pressure": 1002, + "humidity": 76, + "dew_point": -16.71, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 8.55, + "wind_deg": 309, + "wind_gust": 15.72, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770469200, + "temp": -14.13, + "feels_like": -21.13, + "pressure": 1003, + "humidity": 76, + "dew_point": -18.65, + "uvi": 0.27, + "clouds": 100, + "visibility": 10000, + "wind_speed": 8.44, + "wind_deg": 308, + "wind_gust": 16.05, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770472800, + "temp": -13.41, + "feels_like": -20.41, + "pressure": 1004, + "humidity": 76, + "dew_point": -17.82, + "uvi": 0.72, + "clouds": 56, + "visibility": 10000, + "wind_speed": 8.4, + "wind_deg": 311, + "wind_gust": 16, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770476400, + "temp": -12.76, + "feels_like": -19.76, + "pressure": 1004, + "humidity": 78, + "dew_point": -16.79, + "uvi": 1.2, + "clouds": 52, + "visibility": 10000, + "wind_speed": 8.67, + "wind_deg": 317, + "wind_gust": 15.12, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770480000, + "temp": -12.33, + "feels_like": -19.33, + "pressure": 1005, + "humidity": 83, + "dew_point": -15.61, + "uvi": 1.56, + "clouds": 64, + "visibility": 3083, + "wind_speed": 8.8, + "wind_deg": 321, + "wind_gust": 15.19, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770483600, + "temp": -11.87, + "feels_like": -18.87, + "pressure": 1004, + "humidity": 82, + "dew_point": -15.28, + "uvi": 1.56, + "clouds": 71, + "visibility": 8917, + "wind_speed": 8.88, + "wind_deg": 322, + "wind_gust": 15.55, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770487200, + "temp": -11.69, + "feels_like": -18.69, + "pressure": 1005, + "humidity": 79, + "dew_point": -15.5, + "uvi": 1.57, + "clouds": 76, + "visibility": 10000, + "wind_speed": 9.46, + "wind_deg": 324, + "wind_gust": 16.31, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770490800, + "temp": -11.62, + "feels_like": -18.62, + "pressure": 1005, + "humidity": 77, + "dew_point": -15.73, + "uvi": 1.11, + "clouds": 100, + "visibility": 10000, + "wind_speed": 9.8, + "wind_deg": 327, + "wind_gust": 16.18, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770494400, + "temp": -12, + "feels_like": -19, + "pressure": 1006, + "humidity": 75, + "dew_point": -16.48, + "uvi": 0.59, + "clouds": 100, + "visibility": 10000, + "wind_speed": 9.97, + "wind_deg": 328, + "wind_gust": 16.89, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770498000, + "temp": -12.71, + "feels_like": -19.71, + "pressure": 1007, + "humidity": 74, + "dew_point": -17.39, + "uvi": 0.19, + "clouds": 100, + "visibility": 10000, + "wind_speed": 10.12, + "wind_deg": 328, + "wind_gust": 17.9, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770501600, + "temp": -13.43, + "feels_like": -20.43, + "pressure": 1009, + "humidity": 72, + "dew_point": -18.44, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 10.09, + "wind_deg": 329, + "wind_gust": 18.24, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770505200, + "temp": -14.05, + "feels_like": -21.05, + "pressure": 1011, + "humidity": 72, + "dew_point": -19.28, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 10.11, + "wind_deg": 329, + "wind_gust": 18.4, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770508800, + "temp": -14.31, + "feels_like": -21.31, + "pressure": 1013, + "humidity": 72, + "dew_point": -19.61, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 10.18, + "wind_deg": 328, + "wind_gust": 18.77, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770512400, + "temp": -14.29, + "feels_like": -21.29, + "pressure": 1014, + "humidity": 72, + "dew_point": -19.51, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 9.7, + "wind_deg": 330, + "wind_gust": 18.29, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770516000, + "temp": -14.14, + "feels_like": -21.14, + "pressure": 1015, + "humidity": 72, + "dew_point": -19.28, + "uvi": 0, + "clouds": 98, + "visibility": 10000, + "wind_speed": 9.38, + "wind_deg": 330, + "wind_gust": 17.25, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770519600, + "temp": -14.08, + "feels_like": -21.08, + "pressure": 1016, + "humidity": 73, + "dew_point": -19.05, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 8.71, + "wind_deg": 329, + "wind_gust": 16.58, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770523200, + "temp": -14.19, + "feels_like": -21.19, + "pressure": 1016, + "humidity": 74, + "dew_point": -19.05, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 8.24, + "wind_deg": 328, + "wind_gust": 15.71, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770526800, + "temp": -14.38, + "feels_like": -21.38, + "pressure": 1017, + "humidity": 74, + "dew_point": -19.34, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 8.08, + "wind_deg": 326, + "wind_gust": 15.77, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770530400, + "temp": -14.74, + "feels_like": -21.74, + "pressure": 1018, + "humidity": 74, + "dew_point": -19.74, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 7.81, + "wind_deg": 324, + "wind_gust": 15.4, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770534000, + "temp": -15.13, + "feels_like": -22.13, + "pressure": 1019, + "humidity": 73, + "dew_point": -20.25, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 7.57, + "wind_deg": 325, + "wind_gust": 15.39, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770537600, + "temp": -15.57, + "feels_like": -22.57, + "pressure": 1019, + "humidity": 73, + "dew_point": -20.69, + "uvi": 0, + "clouds": 94, + "visibility": 10000, + "wind_speed": 7.36, + "wind_deg": 323, + "wind_gust": 15.29, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770541200, + "temp": -15.98, + "feels_like": -22.98, + "pressure": 1019, + "humidity": 73, + "dew_point": -21.2, + "uvi": 0, + "clouds": 88, + "visibility": 10000, + "wind_speed": 7.37, + "wind_deg": 321, + "wind_gust": 15.7, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770544800, + "temp": -16.36, + "feels_like": -23.36, + "pressure": 1020, + "humidity": 73, + "dew_point": -21.64, + "uvi": 0, + "clouds": 69, + "visibility": 10000, + "wind_speed": 7.62, + "wind_deg": 322, + "wind_gust": 16.29, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770548400, + "temp": -16.63, + "feels_like": -23.63, + "pressure": 1021, + "humidity": 74, + "dew_point": -21.86, + "uvi": 0, + "clouds": 57, + "visibility": 10000, + "wind_speed": 7.52, + "wind_deg": 323, + "wind_gust": 16.46, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770552000, + "temp": -16.84, + "feels_like": -23.84, + "pressure": 1022, + "humidity": 74, + "dew_point": -22.06, + "uvi": 0, + "clouds": 48, + "visibility": 10000, + "wind_speed": 7.59, + "wind_deg": 324, + "wind_gust": 16.2, + "weather": [{ "id": 802, "main": "Clouds", "description": "scattered clouds", "icon": "03d" }], + "pop": 0 + }, + { + "dt": 1770555600, + "temp": -16.57, + "feels_like": -23.57, + "pressure": 1023, + "humidity": 74, + "dew_point": -21.63, + "uvi": 0.3, + "clouds": 2, + "visibility": 10000, + "wind_speed": 7.27, + "wind_deg": 325, + "wind_gust": 14.68, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770559200, + "temp": -15.7, + "feels_like": -22.7, + "pressure": 1023, + "humidity": 76, + "dew_point": -20.43, + "uvi": 0.77, + "clouds": 4, + "visibility": 10000, + "wind_speed": 7.26, + "wind_deg": 324, + "wind_gust": 13.65, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770562800, + "temp": -14.48, + "feels_like": -21.48, + "pressure": 1023, + "humidity": 77, + "dew_point": -18.94, + "uvi": 1.42, + "clouds": 5, + "visibility": 10000, + "wind_speed": 6.7, + "wind_deg": 324, + "wind_gust": 12.19, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770566400, + "temp": -13.34, + "feels_like": -20.34, + "pressure": 1023, + "humidity": 73, + "dew_point": -18.23, + "uvi": 1.98, + "clouds": 5, + "visibility": 10000, + "wind_speed": 6.59, + "wind_deg": 327, + "wind_gust": 10.06, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770570000, + "temp": -11.94, + "feels_like": -18.94, + "pressure": 1022, + "humidity": 74, + "dew_point": -16.63, + "uvi": 2.19, + "clouds": 6, + "visibility": 10000, + "wind_speed": 6.3, + "wind_deg": 325, + "wind_gust": 9.29, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770573600, + "temp": -10.51, + "feels_like": -17.51, + "pressure": 1022, + "humidity": 75, + "dew_point": -14.88, + "uvi": 1.95, + "clouds": 7, + "visibility": 10000, + "wind_speed": 5.98, + "wind_deg": 321, + "wind_gust": 8.89, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770577200, + "temp": -9.42, + "feels_like": -16.42, + "pressure": 1022, + "humidity": 75, + "dew_point": -13.63, + "uvi": 1.39, + "clouds": 72, + "visibility": 10000, + "wind_speed": 5.92, + "wind_deg": 317, + "wind_gust": 8.77, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770580800, + "temp": -8.96, + "feels_like": -15.96, + "pressure": 1023, + "humidity": 79, + "dew_point": -12.4, + "uvi": 0.73, + "clouds": 80, + "visibility": 10000, + "wind_speed": 6.03, + "wind_deg": 312, + "wind_gust": 10.13, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + } + ], + "daily": [ + { + "dt": 1770397200, + "sunrise": 1770379257, + "sunset": 1770416341, + "moonrise": 1770435960, + "moonset": 1770386880, + "moon_phase": 0.66, + "summary": "Expect a day of partly cloudy with snow", + "temp": { "day": -2.5, "min": -11.86, "max": -0.27, "night": -3.88, "eve": -1.03, "morn": -10.39 }, + "feels_like": { "day": -2.5, "night": -7.67, "eve": -3.4, "morn": -14.33 }, + "pressure": 1006, + "humidity": 88, + "dew_point": -4.3, + "wind_speed": 3.05, + "wind_deg": 236, + "wind_gust": 7.82, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13d" }], + "clouds": 95, + "pop": 1, + "snow": 0.69, + "uvi": 2.22 + }, + { + "dt": 1770483600, + "sunrise": 1770465590, + "sunset": 1770502816, + "moonrise": 1770526200, + "moonset": 1770474600, + "moon_phase": 0.69, + "summary": "There will be snow until morning, then partly cloudy", + "temp": { "day": -11.87, "min": -14.31, "max": -3.78, "night": -14.19, "eve": -14.05, "morn": -9.55 }, + "feels_like": { "day": -18.87, "night": -21.19, "eve": -21.05, "morn": -16.55 }, + "pressure": 1004, + "humidity": 82, + "dew_point": -15.28, + "wind_speed": 10.18, + "wind_deg": 328, + "wind_gust": 18.77, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13d" }], + "clouds": 71, + "pop": 1, + "snow": 0.14, + "uvi": 1.57 + }, + { + "dt": 1770570000, + "sunrise": 1770551923, + "sunset": 1770589291, + "moonrise": 0, + "moonset": 1770562440, + "moon_phase": 0.72, + "summary": "Expect a day of partly cloudy with clear spells", + "temp": { "day": -11.94, "min": -16.84, "max": -8.96, "night": -13.75, "eve": -11.11, "morn": -16.63 }, + "feels_like": { "day": -18.94, "night": -20.33, "eve": -18.11, "morn": -23.63 }, + "pressure": 1022, + "humidity": 74, + "dew_point": -16.63, + "wind_speed": 8.08, + "wind_deg": 326, + "wind_gust": 16.46, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "clouds": 6, + "pop": 0, + "uvi": 2.19 + }, + { + "dt": 1770656400, + "sunrise": 1770638253, + "sunset": 1770675765, + "moonrise": 1770616380, + "moonset": 1770650520, + "moon_phase": 0.75, + "summary": "The day will start with clear sky through the late morning hours, transitioning to partly cloudy", + "temp": { "day": -6.9, "min": -17.11, "max": -3.39, "night": -5.77, "eve": -7.87, "morn": -16.94 }, + "feels_like": { "day": -10.1, "night": -5.77, "eve": -7.87, "morn": -16.94 }, + "pressure": 1024, + "humidity": 78, + "dew_point": -10.38, + "wind_speed": 2.5, + "wind_deg": 319, + "wind_gust": 7.03, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "clouds": 83, + "pop": 0, + "uvi": 2.7 + }, + { + "dt": 1770742800, + "sunrise": 1770724583, + "sunset": 1770762240, + "moonrise": 1770706560, + "moonset": 1770739020, + "moon_phase": 0.79, + "summary": "There will be partly cloudy today", + "temp": { "day": -1.46, "min": -10, "max": -0.51, "night": -3.8, "eve": -1.57, "morn": -10 }, + "feels_like": { "day": -1.46, "night": -6.36, "eve": -3.98, "morn": -13.81 }, + "pressure": 1020, + "humidity": 94, + "dew_point": -2.47, + "wind_speed": 1.83, + "wind_deg": 2, + "wind_gust": 2.92, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "clouds": 56, + "pop": 0, + "uvi": 3.1 + }, + { + "dt": 1770829200, + "sunrise": 1770810911, + "sunset": 1770848714, + "moonrise": 1770796620, + "moonset": 1770827880, + "moon_phase": 0.82, + "summary": "The day will start with partly cloudy with snow through the late morning hours, transitioning to partly cloudy with rain", + "temp": { "day": 0.7, "min": -4.02, "max": 2.06, "night": -0.6, "eve": 2.06, "morn": -0.03 }, + "feels_like": { "day": 0.7, "night": -5, "eve": -2, "morn": -3.1 }, + "pressure": 1009, + "humidity": 100, + "dew_point": 0.64, + "wind_speed": 4.4, + "wind_deg": 311, + "wind_gust": 11.56, + "weather": [{ "id": 616, "main": "Snow", "description": "rain and snow", "icon": "13d" }], + "clouds": 100, + "pop": 1, + "rain": 4.38, + "snow": 2.17, + "uvi": 4 + }, + { + "dt": 1770915600, + "sunrise": 1770897237, + "sunset": 1770935188, + "moonrise": 1770886440, + "moonset": 1770917220, + "moon_phase": 0.85, + "summary": "There will be partly cloudy today", + "temp": { "day": 0.2, "min": -4.63, "max": 0.2, "night": -4.63, "eve": -2.9, "morn": -3.67 }, + "feels_like": { "day": -4.8, "night": -10.67, "eve": -8.49, "morn": -8.22 }, + "pressure": 1012, + "humidity": 81, + "dew_point": -2.81, + "wind_speed": 5.52, + "wind_deg": 301, + "wind_gust": 12.97, + "weather": [{ "id": 802, "main": "Clouds", "description": "scattered clouds", "icon": "03d" }], + "clouds": 50, + "pop": 0, + "uvi": 4 + }, + { + "dt": 1771002000, + "sunrise": 1770983562, + "sunset": 1771021662, + "moonrise": 1770975780, + "moonset": 1771007160, + "moon_phase": 0.88, + "summary": "Expect a day of partly cloudy with clear spells", + "temp": { "day": 0.38, "min": -6.39, "max": 0.95, "night": -1.17, "eve": -0.91, "morn": -6.39 }, + "feels_like": { "day": -3.92, "night": -6.3, "eve": -5.42, "morn": -12.89 }, + "pressure": 1017, + "humidity": 80, + "dew_point": -2.71, + "wind_speed": 5.27, + "wind_deg": 298, + "wind_gust": 14.69, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "clouds": 74, + "pop": 0, + "uvi": 4 + } + ] +} diff --git a/tests/mocks/weather_pirateweather.json b/tests/mocks/weather_pirateweather.json new file mode 100644 index 0000000000..75a13969b5 --- /dev/null +++ b/tests/mocks/weather_pirateweather.json @@ -0,0 +1,1665 @@ +{ + "latitude": 40.7128, + "longitude": -74.006, + "timezone": "America/New_York", + "offset": -5.0, + "elevation": 19, + "currently": { + "time": 1770414300, + "summary": "Overcast", + "icon": "cloudy", + "nearestStormDistance": 115.95, + "nearestStormBearing": 233, + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipType": "none", + "temperature": -0.26, + "apparentTemperature": -4.77, + "dewPoint": -7.89, + "humidity": 0.56, + "pressure": 1004.92, + "windSpeed": 2.32, + "windGust": 3.2, + "windBearing": 166, + "cloudCover": 0.97, + "uvIndex": 0.54, + "visibility": 16.09, + "ozone": 401.41 + }, + "minutely": { + "summary": "Overcast for the hour.", + "icon": "cloudy", + "data": [ + { "time": 1770414300, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414360, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414420, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414480, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414540, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414600, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414660, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414720, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414780, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414840, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414900, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414960, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415020, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415080, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415140, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415200, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415260, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415320, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415380, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415440, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415500, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415560, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415620, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415680, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415740, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415800, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415860, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415920, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415980, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416040, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416100, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416160, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416220, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416280, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416340, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416400, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416460, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416520, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416580, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416640, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416700, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416760, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416820, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416880, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416940, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417000, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417060, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417120, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417180, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417240, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417300, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417360, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417420, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417480, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417540, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417600, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417660, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417720, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417780, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417840, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417900, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" } + ] + }, + "hourly": { + "summary": "Hazy tonight and windy starting tomorrow morning.", + "icon": "fog", + "data": [ + { + "time": 1770411600, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.19, + "apparentTemperature": -6.47, + "dewPoint": -6.77, + "humidity": 0.7, + "pressure": 1005.22, + "windSpeed": 3.6, + "windGust": 4.7, + "windBearing": 200, + "cloudCover": 0.77, + "uvIndex": 1.12, + "visibility": 16.09, + "ozone": 402.15, + "nearestStormDistance": 108.83, + "nearestStormBearing": 258 + }, + { + "time": 1770415200, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.11, + "apparentTemperature": -6.3, + "dewPoint": -6.64, + "humidity": 0.71, + "pressure": 1004.82, + "windSpeed": 3.6, + "windGust": 4.77, + "windBearing": 207, + "cloudCover": 0.8, + "uvIndex": 0.35, + "visibility": 14.72, + "ozone": 401.17, + "nearestStormDistance": 118.33, + "nearestStormBearing": 233 + }, + { + "time": 1770418800, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.02, + "apparentTemperature": -6.13, + "dewPoint": -6.5, + "humidity": 0.71, + "pressure": 1004.19, + "windSpeed": 3.6, + "windGust": 4.83, + "windBearing": 213, + "cloudCover": 0.83, + "uvIndex": 0.01, + "visibility": 13.18, + "ozone": 403.38, + "nearestStormDistance": 63.25, + "nearestStormBearing": 270 + }, + { + "time": 1770422400, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.94, + "apparentTemperature": -5.96, + "dewPoint": -6.37, + "humidity": 0.72, + "pressure": 1004.33, + "windSpeed": 3.6, + "windGust": 4.9, + "windBearing": 220, + "cloudCover": 0.86, + "uvIndex": 0.0, + "visibility": 11.65, + "ozone": 406.37, + "nearestStormDistance": 34.89, + "nearestStormBearing": 225 + }, + { + "time": 1770426000, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.22, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.87, + "apparentTemperature": -6.11, + "dewPoint": -6.26, + "humidity": 0.73, + "pressure": 1003.94, + "windSpeed": 3.6, + "windGust": 4.93, + "windBearing": 223, + "cloudCover": 0.87, + "uvIndex": 0.0, + "visibility": 8.58, + "ozone": 408.33, + "nearestStormDistance": 21.08, + "nearestStormBearing": 270 + }, + { + "time": 1770429600, + "summary": "Overcast", + "icon": "cloudy", + "precipIntensity": 0.0, + "precipProbability": 0.27, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.79, + "apparentTemperature": -6.25, + "dewPoint": -6.15, + "humidity": 0.73, + "pressure": 1003.89, + "windSpeed": 3.6, + "windGust": 4.97, + "windBearing": 227, + "cloudCover": 0.88, + "uvIndex": 0.0, + "visibility": 5.5, + "ozone": 405.87, + "nearestStormDistance": 34.89, + "nearestStormBearing": 135 + }, + { + "time": 1770433200, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.72, + "apparentTemperature": -6.4, + "dewPoint": -6.04, + "humidity": 0.74, + "pressure": 1003.77, + "windSpeed": 3.6, + "windGust": 5.0, + "windBearing": 230, + "cloudCover": 0.89, + "uvIndex": 0.0, + "visibility": 2.43, + "ozone": 406.71, + "nearestStormDistance": 21.08, + "nearestStormBearing": 90 + }, + { + "time": 1770436800, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.89, + "apparentTemperature": -6.74, + "dewPoint": -6.17, + "humidity": 0.74, + "pressure": 1003.03, + "windSpeed": 3.87, + "windGust": 5.4, + "windBearing": 237, + "cloudCover": 0.86, + "uvIndex": 0.0, + "visibility": 2.73, + "ozone": 403.27, + "nearestStormDistance": 84.33, + "nearestStormBearing": 90 + }, + { + "time": 1770440400, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.05, + "apparentTemperature": -7.07, + "dewPoint": -6.3, + "humidity": 0.73, + "pressure": 1002.42, + "windSpeed": 4.13, + "windGust": 5.8, + "windBearing": 243, + "cloudCover": 0.84, + "uvIndex": 0.0, + "visibility": 3.03, + "ozone": 408.05, + "nearestStormDistance": 105.41, + "nearestStormBearing": 90 + }, + { + "time": 1770444000, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.22, + "apparentTemperature": -7.41, + "dewPoint": -6.43, + "humidity": 0.73, + "pressure": 1001.45, + "windSpeed": 4.4, + "windGust": 6.2, + "windBearing": 250, + "cloudCover": 0.81, + "uvIndex": 0.0, + "visibility": 3.33, + "ozone": 412.89, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770447600, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.29, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.58, + "apparentTemperature": -8.29, + "dewPoint": -6.62, + "humidity": 0.74, + "pressure": 1000.84, + "windSpeed": 5.33, + "windGust": 7.5, + "windBearing": 260, + "cloudCover": 0.8, + "uvIndex": 0.0, + "visibility": 5.6, + "ozone": 417.97, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770451200, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.24, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.95, + "apparentTemperature": -9.17, + "dewPoint": -6.82, + "humidity": 0.75, + "pressure": 1000.63, + "windSpeed": 6.27, + "windGust": 8.8, + "windBearing": 270, + "cloudCover": 0.78, + "uvIndex": 0.0, + "visibility": 8.4, + "ozone": 417.69, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770454800, + "summary": "Breezy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -3.31, + "apparentTemperature": -10.05, + "dewPoint": -7.01, + "humidity": 0.76, + "pressure": 1000.26, + "windSpeed": 7.2, + "windGust": 10.1, + "windBearing": 280, + "cloudCover": 0.77, + "uvIndex": 0.0, + "visibility": 2.3, + "ozone": 416.64, + "nearestStormDistance": 55.66, + "nearestStormBearing": 180 + }, + { + "time": 1770458400, + "summary": "Breezy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -4.07, + "apparentTemperature": -11.46, + "dewPoint": -8.19, + "humidity": 0.73, + "pressure": 1000.86, + "windSpeed": 8.13, + "windGust": 11.13, + "windBearing": 287, + "cloudCover": 0.73, + "uvIndex": 0.0, + "visibility": 2.5, + "ozone": 430.55, + "nearestStormDistance": 59.55, + "nearestStormBearing": 333 + }, + { + "time": 1770462000, + "summary": "Breezy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -4.82, + "apparentTemperature": -12.87, + "dewPoint": -9.36, + "humidity": 0.71, + "pressure": 1001.53, + "windSpeed": 9.07, + "windGust": 12.17, + "windBearing": 293, + "cloudCover": 0.69, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 444.39, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770465600, + "summary": "Windy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -5.58, + "apparentTemperature": -14.28, + "dewPoint": -10.54, + "humidity": 0.68, + "pressure": 1002.21, + "windSpeed": 10.0, + "windGust": 13.2, + "windBearing": 300, + "cloudCover": 0.65, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 445.07, + "nearestStormDistance": 101.31, + "nearestStormBearing": 63 + }, + { + "time": 1770469200, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -6.72, + "apparentTemperature": -15.95, + "dewPoint": -11.67, + "humidity": 0.68, + "pressure": 1003.26, + "windSpeed": 10.4, + "windGust": 14.17, + "windBearing": 303, + "cloudCover": 0.57, + "uvIndex": 0.21, + "visibility": 16.09, + "ozone": 446.52, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770472800, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.11, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -7.85, + "apparentTemperature": -17.63, + "dewPoint": -12.79, + "humidity": 0.68, + "pressure": 1003.8, + "windSpeed": 10.8, + "windGust": 15.13, + "windBearing": 307, + "cloudCover": 0.49, + "uvIndex": 1.08, + "visibility": 16.09, + "ozone": 451.89, + "nearestStormDistance": 68.99, + "nearestStormBearing": 108 + }, + { + "time": 1770476400, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.99, + "apparentTemperature": -19.3, + "dewPoint": -13.92, + "humidity": 0.68, + "pressure": 1004.89, + "windSpeed": 11.2, + "windGust": 16.1, + "windBearing": 310, + "cloudCover": 0.41, + "uvIndex": 2.15, + "visibility": 6.5, + "ozone": 449.97, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770480000, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.07, + "apparentTemperature": -19.26, + "dewPoint": -14.3, + "humidity": 0.66, + "pressure": 1005.63, + "windSpeed": 11.07, + "windGust": 16.07, + "windBearing": 313, + "cloudCover": 0.39, + "uvIndex": 2.87, + "visibility": 4.3, + "ozone": 447.68, + "nearestStormDistance": 129.75, + "nearestStormBearing": 80 + }, + { + "time": 1770483600, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.14, + "apparentTemperature": -19.23, + "dewPoint": -14.68, + "humidity": 0.64, + "pressure": 1006.14, + "windSpeed": 10.93, + "windGust": 16.03, + "windBearing": 317, + "cloudCover": 0.36, + "uvIndex": 3.23, + "visibility": 9.5, + "ozone": 460.33, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770487200, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.22, + "apparentTemperature": -19.19, + "dewPoint": -15.06, + "humidity": 0.62, + "pressure": 1006.64, + "windSpeed": 10.8, + "windGust": 16.0, + "windBearing": 320, + "cloudCover": 0.34, + "uvIndex": 3.23, + "visibility": 16.09, + "ozone": 466.32, + "nearestStormDistance": 84.43, + "nearestStormBearing": 56 + }, + { + "time": 1770490800, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.06, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.12, + "apparentTemperature": -19.17, + "dewPoint": -15.37, + "humidity": 0.6, + "pressure": 1007.56, + "windSpeed": 10.93, + "windGust": 16.2, + "windBearing": 320, + "cloudCover": 0.31, + "uvIndex": 2.88, + "visibility": 16.09, + "ozone": 461.77, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770494400, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.02, + "apparentTemperature": -19.16, + "dewPoint": -15.67, + "humidity": 0.57, + "pressure": 1008.89, + "windSpeed": 11.07, + "windGust": 16.4, + "windBearing": 320, + "cloudCover": 0.28, + "uvIndex": 2.08, + "visibility": 16.09, + "ozone": 460.11, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770498000, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.92, + "apparentTemperature": -19.14, + "dewPoint": -15.98, + "humidity": 0.55, + "pressure": 1009.92, + "windSpeed": 11.2, + "windGust": 16.6, + "windBearing": 320, + "cloudCover": 0.25, + "uvIndex": 1.23, + "visibility": 16.09, + "ozone": 464.16, + "nearestStormDistance": 163.12, + "nearestStormBearing": 38 + }, + { + "time": 1770501600, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.09, + "apparentTemperature": -19.43, + "dewPoint": -16.42, + "humidity": 0.54, + "pressure": 1011.26, + "windSpeed": 11.33, + "windGust": 16.13, + "windBearing": 320, + "cloudCover": 0.23, + "uvIndex": 0.43, + "visibility": 16.09, + "ozone": 482.48, + "nearestStormDistance": 129.29, + "nearestStormBearing": 99 + }, + { + "time": 1770505200, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.25, + "apparentTemperature": -19.71, + "dewPoint": -16.86, + "humidity": 0.53, + "pressure": 1013.04, + "windSpeed": 11.47, + "windGust": 15.67, + "windBearing": 320, + "cloudCover": 0.22, + "uvIndex": 0.01, + "visibility": 16.09, + "ozone": 484.32, + "nearestStormDistance": 105.41, + "nearestStormBearing": 90 + }, + { + "time": 1770508800, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.42, + "apparentTemperature": -20.0, + "dewPoint": -17.3, + "humidity": 0.52, + "pressure": 1014.7, + "windSpeed": 11.6, + "windGust": 15.2, + "windBearing": 320, + "cloudCover": 0.2, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 476.4, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770512400, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.84, + "apparentTemperature": -20.51, + "dewPoint": -17.58, + "humidity": 0.53, + "pressure": 1015.83, + "windSpeed": 11.2, + "windGust": 14.73, + "windBearing": 320, + "cloudCover": 0.2, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 488.47, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770516000, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -10.26, + "apparentTemperature": -21.02, + "dewPoint": -17.87, + "humidity": 0.53, + "pressure": 1016.42, + "windSpeed": 10.8, + "windGust": 14.27, + "windBearing": 320, + "cloudCover": 0.19, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 488.94, + "nearestStormDistance": 42.17, + "nearestStormBearing": 90 + }, + { + "time": 1770519600, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -10.68, + "apparentTemperature": -21.53, + "dewPoint": -18.15, + "humidity": 0.54, + "pressure": 1017.26, + "windSpeed": 10.4, + "windGust": 13.8, + "windBearing": 320, + "cloudCover": 0.19, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 476.31, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770523200, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -11.0, + "apparentTemperature": -21.6, + "dewPoint": -18.21, + "humidity": 0.55, + "pressure": 1017.96, + "windSpeed": 9.87, + "windGust": 13.07, + "windBearing": 320, + "cloudCover": 0.19, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 472.62, + "nearestStormDistance": 84.43, + "nearestStormBearing": 56 + }, + { + "time": 1770526800, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -11.31, + "apparentTemperature": -21.67, + "dewPoint": -18.28, + "humidity": 0.55, + "pressure": 1018.54, + "windSpeed": 9.33, + "windGust": 12.33, + "windBearing": 320, + "cloudCover": 0.18, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 473.0, + "nearestStormDistance": 104.96, + "nearestStormBearing": 45 + }, + { + "time": 1770530400, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -11.63, + "apparentTemperature": -21.74, + "dewPoint": -18.34, + "humidity": 0.56, + "pressure": 1018.94, + "windSpeed": 8.8, + "windGust": 11.6, + "windBearing": 320, + "cloudCover": 0.18, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 471.38, + "nearestStormDistance": 128.27, + "nearestStormBearing": 36 + }, + { + "time": 1770534000, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -11.85, + "apparentTemperature": -21.99, + "dewPoint": -18.42, + "humidity": 0.57, + "pressure": 1019.57, + "windSpeed": 8.53, + "windGust": 11.43, + "windBearing": 320, + "cloudCover": 0.2, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 469.74, + "nearestStormDistance": 163.12, + "nearestStormBearing": 38 + }, + { + "time": 1770537600, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.08, + "apparentTemperature": -22.23, + "dewPoint": -18.51, + "humidity": 0.58, + "pressure": 1020.57, + "windSpeed": 8.27, + "windGust": 11.27, + "windBearing": 320, + "cloudCover": 0.22, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 469.32, + "nearestStormDistance": 198.1, + "nearestStormBearing": 39 + }, + { + "time": 1770541200, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.3, + "apparentTemperature": -22.48, + "dewPoint": -18.59, + "humidity": 0.59, + "pressure": 1020.96, + "windSpeed": 8.0, + "windGust": 11.1, + "windBearing": 320, + "cloudCover": 0.24, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 470.4, + "nearestStormDistance": 210.33, + "nearestStormBearing": 45 + }, + { + "time": 1770544800, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.29, + "apparentTemperature": -22.26, + "dewPoint": -18.48, + "humidity": 0.6, + "pressure": 1021.44, + "windSpeed": 7.73, + "windGust": 10.67, + "windBearing": 317, + "cloudCover": 0.26, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 467.82, + "nearestStormDistance": 223.94, + "nearestStormBearing": 49 + }, + { + "time": 1770548400, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.29, + "apparentTemperature": -22.05, + "dewPoint": -18.38, + "humidity": 0.61, + "pressure": 1021.88, + "windSpeed": 7.47, + "windGust": 10.23, + "windBearing": 313, + "cloudCover": 0.27, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 467.33, + "nearestStormDistance": 222.15, + "nearestStormBearing": 35 + }, + { + "time": 1770552000, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.28, + "apparentTemperature": -21.83, + "dewPoint": -18.27, + "humidity": 0.62, + "pressure": 1022.51, + "windSpeed": 7.2, + "windGust": 9.8, + "windBearing": 310, + "cloudCover": 0.29, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 470.61, + "nearestStormDistance": 222.15, + "nearestStormBearing": 35 + }, + { + "time": 1770555600, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.06, + "apparentTemperature": -21.37, + "dewPoint": -17.87, + "humidity": 0.63, + "pressure": 1023.17, + "windSpeed": 7.07, + "windGust": 9.83, + "windBearing": 313, + "cloudCover": 0.32, + "uvIndex": 0.23, + "visibility": 16.09, + "ozone": 475.74, + "nearestStormDistance": 198.1, + "nearestStormBearing": 39 + }, + { + "time": 1770559200, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -11.85, + "apparentTemperature": -20.92, + "dewPoint": -17.48, + "humidity": 0.63, + "pressure": 1023.18, + "windSpeed": 6.93, + "windGust": 9.87, + "windBearing": 317, + "cloudCover": 0.35, + "uvIndex": 1.09, + "visibility": 16.09, + "ozone": 473.89, + "nearestStormDistance": 210.33, + "nearestStormBearing": 45 + }, + { + "time": 1770562800, + "summary": "Breezy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -11.63, + "apparentTemperature": -20.46, + "dewPoint": -17.08, + "humidity": 0.64, + "pressure": 1023.83, + "windSpeed": 6.8, + "windGust": 9.9, + "windBearing": 320, + "cloudCover": 0.38, + "uvIndex": 2.2, + "visibility": 16.09, + "ozone": 467.51, + "nearestStormDistance": 233.17, + "nearestStormBearing": 40 + }, + { + "time": 1770566400, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -10.88, + "apparentTemperature": -19.39, + "dewPoint": -16.45, + "humidity": 0.64, + "pressure": 1024.02, + "windSpeed": 6.53, + "windGust": 9.77, + "windBearing": 317, + "cloudCover": 0.41, + "uvIndex": 3.21, + "visibility": 16.09, + "ozone": 453.24, + "nearestStormDistance": 233.17, + "nearestStormBearing": 40 + }, + { + "time": 1770570000, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -10.12, + "apparentTemperature": -18.31, + "dewPoint": -15.82, + "humidity": 0.63, + "pressure": 1023.84, + "windSpeed": 6.27, + "windGust": 9.63, + "windBearing": 313, + "cloudCover": 0.45, + "uvIndex": 3.87, + "visibility": 16.09, + "ozone": 444.43, + "nearestStormDistance": 247.0, + "nearestStormBearing": 32 + }, + { + "time": 1770573600, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.37, + "apparentTemperature": -17.24, + "dewPoint": -15.19, + "humidity": 0.63, + "pressure": 1023.45, + "windSpeed": 6.0, + "windGust": 9.5, + "windBearing": 310, + "cloudCover": 0.48, + "uvIndex": 3.98, + "visibility": 16.09, + "ozone": 441.37, + "nearestStormDistance": 280.82, + "nearestStormBearing": 45 + }, + { + "time": 1770577200, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.06, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.97, + "apparentTemperature": -16.72, + "dewPoint": -14.92, + "humidity": 0.62, + "pressure": 1021.09, + "windSpeed": 6.07, + "windGust": 9.37, + "windBearing": 310, + "cloudCover": 0.49, + "uvIndex": 3.5, + "visibility": 16.09, + "ozone": 440.69, + "nearestStormDistance": 291.96, + "nearestStormBearing": 37 + }, + { + "time": 1770580800, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.06, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.58, + "apparentTemperature": -16.2, + "dewPoint": -14.64, + "humidity": 0.62, + "pressure": 1021.18, + "windSpeed": 6.13, + "windGust": 9.23, + "windBearing": 310, + "cloudCover": 0.51, + "uvIndex": 2.58, + "visibility": 16.09, + "ozone": 433.53, + "nearestStormDistance": 303.53, + "nearestStormBearing": 41 + } + ] + }, + "daily": { + "summary": "Snow next Friday, with high temperatures peaking at 2°C on Wednesday.", + "icon": "snow", + "data": [ + { + "time": 1770354000, + "summary": "Hazy overnight.", + "icon": "fog", + "sunriseTime": 1770379258, + "sunsetTime": 1770416384, + "moonPhase": 0.66, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770354000, + "precipProbability": 0.33, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -2.02, + "temperatureHighTime": 1770418800, + "temperatureLow": -4.82, + "temperatureLowTime": 1770462000, + "apparentTemperatureHigh": -5.66, + "apparentTemperatureHighTime": 1770411600, + "apparentTemperatureLow": -14.37, + "apparentTemperatureLowTime": 1770462000, + "dewPoint": -9.17, + "humidity": 0.71, + "pressure": 1007.52, + "windSpeed": 3.01, + "windGust": 4.09, + "windGustTime": 1770436800, + "windBearing": 281, + "cloudCover": 0.63, + "uvIndex": 3.7, + "uvIndexTime": 1770400800, + "visibility": 13.85, + "temperatureMin": -8.2, + "temperatureMinTime": 1770379200, + "temperatureMax": -1.72, + "temperatureMaxTime": 1770433200, + "apparentTemperatureMin": -13.29, + "apparentTemperatureMinTime": 1770379200, + "apparentTemperatureMax": -5.66, + "apparentTemperatureMaxTime": 1770411600 + }, + { + "time": 1770440400, + "summary": "Windy throughout the day.", + "icon": "wind", + "sunriseTime": 1770465591, + "sunsetTime": 1770502858, + "moonPhase": 0.69, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770440400, + "precipProbability": 0.33, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -5.58, + "temperatureHighTime": 1770465600, + "temperatureLow": -12.3, + "temperatureLowTime": 1770541200, + "apparentTemperatureHigh": -15.88, + "apparentTemperatureHighTime": 1770465600, + "apparentTemperatureLow": -21.7, + "apparentTemperatureLowTime": 1770519600, + "dewPoint": -13.05, + "humidity": 0.64, + "pressure": 1007.22, + "windSpeed": 9.57, + "windGust": 13.35, + "windGustTime": 1770498000, + "windBearing": 302, + "cloudCover": 0.45, + "uvIndex": 3.23, + "uvIndexTime": 1770483600, + "visibility": 11.95, + "temperatureMin": -11.0, + "temperatureMinTime": 1770523200, + "temperatureMax": -2.05, + "temperatureMaxTime": 1770440400, + "apparentTemperatureMin": -21.7, + "apparentTemperatureMinTime": 1770519600, + "apparentTemperatureMax": -7.86, + "apparentTemperatureMaxTime": 1770440400 + }, + { + "time": 1770526800, + "summary": "Breezy in the morning.", + "icon": "wind", + "sunriseTime": 1770551923, + "sunsetTime": 1770589332, + "moonPhase": 0.72, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770526800, + "precipProbability": 0.07, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -7.97, + "temperatureHighTime": 1770591600, + "temperatureLow": -10.64, + "temperatureLowTime": 1770634800, + "apparentTemperatureHigh": -14.7, + "apparentTemperatureHighTime": 1770584400, + "apparentTemperatureLow": -17.9, + "apparentTemperatureLowTime": 1770634800, + "dewPoint": -16.36, + "humidity": 0.61, + "pressure": 1022.11, + "windSpeed": 6.89, + "windGust": 9.71, + "windGustTime": 1770526800, + "windBearing": 313, + "cloudCover": 0.37, + "uvIndex": 3.98, + "uvIndexTime": 1770573600, + "visibility": 16.09, + "temperatureMin": -12.3, + "temperatureMinTime": 1770541200, + "temperatureMax": -7.86, + "temperatureMaxTime": 1770595200, + "apparentTemperatureMin": -21.66, + "apparentTemperatureMinTime": 1770541200, + "apparentTemperatureMax": -14.7, + "apparentTemperatureMaxTime": 1770584400 + }, + { + "time": 1770613200, + "summary": "Mostly clear until night.", + "icon": "clear-day", + "sunriseTime": 1770638253, + "sunsetTime": 1770675806, + "moonPhase": 0.75, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770613200, + "precipProbability": 0.07, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -4.77, + "temperatureHighTime": 1770670800, + "temperatureLow": -7.39, + "temperatureLowTime": 1770721200, + "apparentTemperatureHigh": -10.59, + "apparentTemperatureHighTime": 1770670800, + "apparentTemperatureLow": -14.19, + "apparentTemperatureLowTime": 1770714000, + "dewPoint": -13.37, + "humidity": 0.64, + "pressure": 1023.19, + "windSpeed": 5.64, + "windGust": 8.0, + "windGustTime": 1770670800, + "windBearing": 306, + "cloudCover": 0.35, + "uvIndex": 3.54, + "uvIndexTime": 1770660000, + "visibility": 16.09, + "temperatureMin": -10.96, + "temperatureMinTime": 1770638400, + "temperatureMax": -4.77, + "temperatureMaxTime": 1770670800, + "apparentTemperatureMin": -18.23, + "apparentTemperatureMinTime": 1770638400, + "apparentTemperatureMax": -10.59, + "apparentTemperatureMaxTime": 1770670800 + }, + { + "time": 1770699600, + "summary": "Mostly clear until evening.", + "icon": "clear-day", + "sunriseTime": 1770724581, + "sunsetTime": 1770762279, + "moonPhase": 0.78, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770699600, + "precipProbability": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -1.3, + "temperatureHighTime": 1770757200, + "temperatureLow": -4.83, + "temperatureLowTime": 1770807600, + "apparentTemperatureHigh": -6.5, + "apparentTemperatureHighTime": 1770757200, + "apparentTemperatureLow": -10.85, + "apparentTemperatureLowTime": 1770807600, + "dewPoint": -10.03, + "humidity": 0.65, + "pressure": 1021.43, + "windSpeed": 4.8, + "windGust": 6.78, + "windGustTime": 1770699600, + "windBearing": 303, + "cloudCover": 0.37, + "uvIndex": 4.35, + "uvIndexTime": 1770746400, + "visibility": 15.2, + "temperatureMin": -7.39, + "temperatureMinTime": 1770721200, + "temperatureMax": -1.3, + "temperatureMaxTime": 1770757200, + "apparentTemperatureMin": -14.19, + "apparentTemperatureMinTime": 1770714000, + "apparentTemperatureMax": -6.5, + "apparentTemperatureMaxTime": 1770757200 + }, + { + "time": 1770786000, + "summary": "Hazy in the afternoon.", + "icon": "fog", + "sunriseTime": 1770810908, + "sunsetTime": 1770848753, + "moonPhase": 0.81, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770786000, + "precipProbability": 0.08, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": 2.11, + "temperatureHighTime": 1770836400, + "temperatureLow": -5.5, + "temperatureLowTime": 1770865200, + "apparentTemperatureHigh": -2.01, + "apparentTemperatureHighTime": 1770836400, + "apparentTemperatureLow": -8.93, + "apparentTemperatureLowTime": 1770876000, + "dewPoint": -6.87, + "humidity": 0.78, + "pressure": 1018.44, + "windSpeed": 3.33, + "windGust": 7.15, + "windGustTime": 1770854400, + "windBearing": 303, + "cloudCover": 0.5, + "uvIndex": 0.47, + "uvIndexTime": 1770832800, + "visibility": 10.62, + "temperatureMin": -5.5, + "temperatureMinTime": 1770865200, + "temperatureMax": 2.11, + "temperatureMaxTime": 1770836400, + "apparentTemperatureMin": -10.85, + "apparentTemperatureMinTime": 1770811200, + "apparentTemperatureMax": -2.01, + "apparentTemperatureMaxTime": 1770836400 + }, + { + "time": 1770872400, + "summary": "Possible snow (< 4 cm.) starting in the evening.", + "icon": "partly-cloudy-day", + "sunriseTime": 1770897234, + "sunsetTime": 1770935226, + "moonPhase": 0.84, + "precipIntensity": 0.15, + "precipIntensityMax": 1.008, + "precipIntensityMaxTime": 1770955200, + "precipProbability": 0.12, + "precipAccumulation": 0.9236, + "precipType": "snow", + "temperatureHigh": -0.19, + "temperatureHighTime": 1770919200, + "temperatureLow": -2.21, + "temperatureLowTime": 1770962400, + "apparentTemperatureHigh": 1.12, + "apparentTemperatureHighTime": 1770919200, + "apparentTemperatureLow": -7.73, + "apparentTemperatureLowTime": 1770980400, + "dewPoint": -4.45, + "humidity": 0.78, + "pressure": 1023.22, + "windSpeed": 0.95, + "windGust": 11.32, + "windGustTime": 1770886800, + "windBearing": 248, + "cloudCover": 0.47, + "uvIndex": 3.94, + "uvIndexTime": 1770919200, + "visibility": 16.09, + "temperatureMin": -5.35, + "temperatureMinTime": 1770872400, + "temperatureMax": -0.19, + "temperatureMaxTime": 1770919200, + "apparentTemperatureMin": -8.93, + "apparentTemperatureMinTime": 1770876000, + "apparentTemperatureMax": 1.12, + "apparentTemperatureMaxTime": 1770919200 + }, + { + "time": 1770958800, + "summary": "Light snow (< 10 cm.) throughout the day.", + "icon": "snow", + "sunriseTime": 1770983559, + "sunsetTime": 1771021699, + "moonPhase": 0.87, + "precipIntensity": 0.381, + "precipIntensityMax": 1.368, + "precipIntensityMaxTime": 1770962400, + "precipProbability": 0.34, + "precipAccumulation": 4.0291, + "precipType": "snow", + "temperatureHigh": -0.77, + "temperatureHighTime": 1771023600, + "temperatureLow": -0.81, + "temperatureLowTime": 1771048800, + "apparentTemperatureHigh": -3.43, + "apparentTemperatureHighTime": 1771005600, + "apparentTemperatureLow": -5.95, + "apparentTemperatureLowTime": 1771059600, + "dewPoint": -2.67, + "humidity": 0.8, + "pressure": 1015.78, + "windSpeed": 3.24, + "windGust": 12.21, + "windGustTime": 1771038000, + "windBearing": 30, + "cloudCover": 0.36, + "uvIndex": 3.8, + "uvIndexTime": 1771005600, + "visibility": 16.09, + "temperatureMin": -2.21, + "temperatureMinTime": 1770962400, + "temperatureMax": -0.64, + "temperatureMaxTime": 1771027200, + "apparentTemperatureMin": -7.91, + "apparentTemperatureMinTime": 1770984000, + "apparentTemperatureMax": -3.43, + "apparentTemperatureMaxTime": 1771005600 + } + ] + }, + "alerts": [ + { + "title": "Extreme Cold Warning", + "regions": [ + "Eastern Passaic", + "Hudson", + "Western Bergen", + "Eastern Bergen", + "Western Essex", + "Eastern Essex", + "Western Union", + "Eastern Union", + "Putnam", + "Rockland", + "Northern Westchester", + "Southern Westchester", + "New York (Manhattan)", + "Bronx", + "Richmond (Staten Is.)", + "Kings (Brooklyn)", + "Northern Queens", + "Southern Queens" + ], + "severity": "Severe", + "time": 1770402120, + "expires": 1770458400, + "description": "* WHAT...For the Wind Advisory, northwest winds 20 to 30 mph with gusts up to 50 mph expected. For the Extreme Cold Warning, dangerously cold wind chills as low as 20 below expected.\n* WHERE...Portions of northeast New Jersey and southeast New York.\n* WHEN...For the Wind Advisory, from 9 AM Saturday to midnight EST Saturday Night. For the Extreme Cold Warning, from 10 AM Saturday to 1 PM EST Sunday.\n* IMPACTS...Gusty winds will blow around unsecured objects. Tree limbs could be blown down and a few power outages may result. The cold wind chills could cause frostbite on exposed skin in as little as 30 minutes.", + "uri": "https://api.weather.gov/alerts/urn:oid:2.49.0.1.840.0.5df73ec191a300e305a2e7beb31cdbaded01fd49.004.1" + }, + { + "title": "Wind Advisory", + "regions": [ + "Eastern Passaic", + "Hudson", + "Western Bergen", + "Eastern Bergen", + "Western Essex", + "Eastern Essex", + "Western Union", + "Eastern Union", + "Putnam", + "Rockland", + "Northern Westchester", + "Southern Westchester", + "New York (Manhattan)", + "Bronx", + "Richmond (Staten Is.)", + "Kings (Brooklyn)", + "Northern Queens", + "Southern Queens" + ], + "severity": "Moderate", + "time": 1770402120, + "expires": 1770458400, + "description": "* WHAT...For the Wind Advisory, northwest winds 20 to 30 mph with gusts up to 50 mph expected. For the Extreme Cold Warning, dangerously cold wind chills as low as 20 below expected.\n* WHERE...Portions of northeast New Jersey and southeast New York.\n* WHEN...For the Wind Advisory, from 9 AM Saturday to midnight EST Saturday Night. For the Extreme Cold Warning, from 10 AM Saturday to 1 PM EST Sunday.\n* IMPACTS...Gusty winds will blow around unsecured objects. Tree limbs could be blown down and a few power outages may result. The cold wind chills could cause frostbite on exposed skin in as little as 30 minutes.", + "uri": "https://api.weather.gov/alerts/urn:oid:2.49.0.1.840.0.5df73ec191a300e305a2e7beb31cdbaded01fd49.004.2" + } + ], + "flags": { + "sources": ["ETOPO1", "hrrrsubh", "rtma_ru", "hrrr_0-18", "nbm", "nbm_fire", "dwd_mosmix", "ecmwf_ifs", "hrrr_18-48", "gfs", "gefs"], + "sourceTimes": { + "hrrr_subh": "2026-02-06 19Z", + "rtma_ru": "2026-02-06 21:15Z", + "hrrr_0-18": "2026-02-06 19Z", + "nbm": "2026-02-03 23Z", + "nbm_fire": "2026-02-06 12Z", + "dwd_mosmix": "2026-02-06 20Z", + "ecmwf_ifs": "2026-02-06 12Z", + "hrrr_18-48": "2026-02-06 18Z", + "gfs": "2026-02-06 12Z", + "gefs": "2026-02-06 12Z" + }, + "nearest-station": 10.96, + "units": "si", + "version": "V2.9.1" + } +} diff --git a/tests/mocks/weather_smhi.json b/tests/mocks/weather_smhi.json new file mode 100644 index 0000000000..c08a6e85b0 --- /dev/null +++ b/tests/mocks/weather_smhi.json @@ -0,0 +1,1907 @@ +{ + "approvedTime": "2026-02-06T21:31:33Z", + "referenceTime": "2026-02-06T21:00:00Z", + "geometry": { "type": "Point", "coordinates": [[18.089437, 59.339222]] }, + "timeSeries": [ + { + "validTime": "2026-02-06T22:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [40] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1013.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-06T23:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1013.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.9] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T01:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [37] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.2] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T02:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [31] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.6] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T03:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [33] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T04:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [35] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [89] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.4] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T05:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [35] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [89] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [11.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.6] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [37] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1015.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.5] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T07:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.7] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [36] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1015.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [12.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T08:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [42] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1016.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [14.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T09:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [41] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [82] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1017.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [17.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T10:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.7] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [44] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [77] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1017.8] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [20.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T11:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [48] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [64] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1018.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [28.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-3.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [47] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [50] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1018.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [38.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T13:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-3.6] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [42] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [52] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1018.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T14:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-3.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [71] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1018.4] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [23.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T15:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-3.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [32] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [76] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1018.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T16:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [31] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1019.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T17:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.6] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [84] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1019.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [36] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1020.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [13.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T19:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [33] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1020.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [13.7] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T20:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [32] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [84] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1021.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [15.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [2] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T21:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [32] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [90] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1021.4] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [2] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T22:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [90] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1021.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [10.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [2] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-07T23:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [44] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.1] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [89] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1022.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [46] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1022.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [13.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T01:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [53] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.9] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [84] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1022.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T02:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [49] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [84] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1023.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T03:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [39] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [85] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1023.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T04:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [40] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1023.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [5.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T05:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [46] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [5.6] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [63] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.2] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [83] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T07:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [55] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [83] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [16.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T08:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [54] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [83] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.8] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [15.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T09:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [53] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [85] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [14.7] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T10:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-3.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [66] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [82] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T11:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-2.6] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [103] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [64] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [29.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-2.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [116] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.2] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [55] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T13:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-2.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [118] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [54] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-08T14:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-2.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [123] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [55] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [35.2] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-08T15:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-2.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [120] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [60] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1025.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [31.5] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-08T16:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [116] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.1] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [65] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.8] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T17:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [115] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.1] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [71] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [24.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.7] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [107] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [78] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [19.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T19:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [117] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.4] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [17.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-08T20:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [124] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [75.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T21:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [138] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [18.0] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-08T22:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [157] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1024.0] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [18.2] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-08T23:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [174] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1023.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [18.2] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-09T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [182] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.2] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [79] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1023.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [18.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-09T03:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.5] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [223] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.6] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.9] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [78] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1021.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [19.5] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-09T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [251] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [77] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1020.4] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [20.4] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [-0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [-0.0] } + ] + }, + { + "validTime": "2026-02-09T09:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [264] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [76] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1019.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [21.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-09T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [254] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.8] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [84] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1017.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [19.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-09T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-9.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [250] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1015.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [22.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-10T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-9.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [271] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [0.8] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1012.9] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [7.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-10T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-10.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [253] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1009.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [8.2] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-10T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-9.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [249] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.3] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1006.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [10.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [-9] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [0] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-10T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-10.3] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [318] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.0] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [85] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1003.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [13.4] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-11T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-13.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [314] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [0.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [91] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1001.5] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [13.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-11T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-11.2] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [348] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [1.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [88] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [999.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [43.7] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.2] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-11T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [344] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [2.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [82] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [998.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [49.2] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [0] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.2] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-11T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [52] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [3.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [6.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [996.1] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [40.7] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.3] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-12T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [49] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [994.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [39.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.2] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.4] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-12T06:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-6.6] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [56] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.9] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [87] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [993.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [29.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.3] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-12T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-5.7] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [55] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.5] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [10.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [81] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [993.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [31.5] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.5] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-12T18:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.1] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [45] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [10.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [994.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [31.7] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.2] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.4] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-13T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [38] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.0] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [9.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [86] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [994.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [31.1] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.6] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [88] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [6] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-13T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.7] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [19] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [5.4] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [10.5] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [80] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [996.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [33.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [8] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.5] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-14T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-10.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [3] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.7] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [9.4] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [85] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [999.6] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [37.5] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [7] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.3] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-14T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-8.0] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [350] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.9] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [9.6] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [75] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1002.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [38.9] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.3] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-15T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-11.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [321] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.1] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [83] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1007.3] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [40.6] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.3] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-15T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-7.9] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [304] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [72] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1011.2] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [43.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.4] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-16T00:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-9.4] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [292] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.2] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [7.7] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [85] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1013.8] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [43.3] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [5] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [1] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [3] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + }, + { + "validTime": "2026-02-16T12:00:00Z", + "parameters": [ + { "name": "t", "levelType": "hl", "level": 2, "unit": "Cel", "values": [-4.8] }, + { "name": "wd", "levelType": "hl", "level": 10, "unit": "degree", "values": [295] }, + { "name": "ws", "levelType": "hl", "level": 10, "unit": "m/s", "values": [4.1] }, + { "name": "gust", "levelType": "hl", "level": 10, "unit": "m/s", "values": [8.3] }, + { "name": "r", "levelType": "hl", "level": 2, "unit": "percent", "values": [78] }, + { "name": "msl", "levelType": "hmsl", "level": 0, "unit": "hPa", "values": [1014.7] }, + { "name": "vis", "levelType": "hl", "level": 2, "unit": "km", "values": [45.8] }, + { "name": "tstm", "levelType": "hl", "level": 0, "unit": "percent", "values": [0] }, + { "name": "tcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [6] }, + { "name": "lcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [4] }, + { "name": "mcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [3] }, + { "name": "hcc_mean", "levelType": "hl", "level": 0, "unit": "octas", "values": [2] }, + { "name": "pmean", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "pmin", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.1] }, + { "name": "pmax", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.2] }, + { "name": "pmedian", "levelType": "hl", "level": 0, "unit": "kg/m2/h", "values": [0.0] }, + { "name": "spp", "levelType": "hl", "level": 0, "unit": "percent", "values": [100] }, + { "name": "pcat", "levelType": "hl", "level": 0, "unit": "category", "values": [1] }, + { "name": "Wsymb2", "levelType": "hl", "level": 0, "unit": "category", "values": [4] }, + { "name": "tp", "levelType": "hl", "level": 0, "unit": "kg/m2", "values": [0.0] } + ] + } + ] +} diff --git a/tests/mocks/weather_ukmetoffice.json b/tests/mocks/weather_ukmetoffice.json new file mode 100644 index 0000000000..1a5663e83c --- /dev/null +++ b/tests/mocks/weather_ukmetoffice.json @@ -0,0 +1,1062 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-0.12480000000000001, 51.5081, 11.0] }, + "properties": { + "location": { "name": "London" }, + "requestPointDistance": 221.7807, + "modelRunDate": "2026-02-07T12:00Z", + "timeSeries": [ + { + "time": "2026-02-07T12:00Z", + "screenTemperature": 9.56, + "maxScreenAirTemp": 9.56, + "minScreenAirTemp": 9.11, + "screenDewPointTemperature": 8.51, + "feelsLikeTemperature": 8.74, + "windSpeed10m": 1.9, + "windDirectionFrom10m": 165, + "windGustSpeed10m": 7.72, + "max10mWindGust": 9.32, + "visibility": 8550, + "screenRelativeHumidity": 93.08, + "mslp": 99440, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 15 + }, + { + "time": "2026-02-07T13:00Z", + "screenTemperature": 9.67, + "maxScreenAirTemp": 9.69, + "minScreenAirTemp": 9.56, + "screenDewPointTemperature": 8.39, + "feelsLikeTemperature": 8.76, + "windSpeed10m": 2.13, + "windDirectionFrom10m": 188, + "windGustSpeed10m": 7.31, + "max10mWindGust": 8.26, + "visibility": 7592, + "screenRelativeHumidity": 91.56, + "mslp": 99435, + "uvIndex": 1, + "significantWeatherCode": 11, + "precipitationRate": 0.06, + "totalPrecipAmount": 0.04, + "totalSnowAmount": 0, + "probOfPrecipitation": 33 + }, + { + "time": "2026-02-07T14:00Z", + "screenTemperature": 9.91, + "maxScreenAirTemp": 10.01, + "minScreenAirTemp": 9.67, + "screenDewPointTemperature": 8.62, + "feelsLikeTemperature": 8.29, + "windSpeed10m": 3.22, + "windDirectionFrom10m": 189, + "windGustSpeed10m": 8.15, + "max10mWindGust": 8.64, + "visibility": 9509, + "screenRelativeHumidity": 91.56, + "mslp": 99496, + "uvIndex": 1, + "significantWeatherCode": 14, + "precipitationRate": 1.5, + "totalPrecipAmount": 0.23, + "totalSnowAmount": 0, + "probOfPrecipitation": 62 + }, + { + "time": "2026-02-07T15:00Z", + "screenTemperature": 10.21, + "maxScreenAirTemp": 10.4, + "minScreenAirTemp": 9.91, + "screenDewPointTemperature": 8.5, + "feelsLikeTemperature": 8.19, + "windSpeed10m": 4.1, + "windDirectionFrom10m": 184, + "windGustSpeed10m": 9.49, + "max10mWindGust": 9.56, + "visibility": 9666, + "screenRelativeHumidity": 89.1, + "mslp": 99550, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.24, + "totalPrecipAmount": 0.09, + "totalSnowAmount": 0, + "probOfPrecipitation": 55 + }, + { + "time": "2026-02-07T16:00Z", + "screenTemperature": 10.22, + "maxScreenAirTemp": 10.24, + "minScreenAirTemp": 10.18, + "screenDewPointTemperature": 8.24, + "feelsLikeTemperature": 8.28, + "windSpeed10m": 3.92, + "windDirectionFrom10m": 187, + "windGustSpeed10m": 8.95, + "max10mWindGust": 9.64, + "visibility": 7525, + "screenRelativeHumidity": 87.43, + "mslp": 99620, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.53, + "totalPrecipAmount": 0.08, + "totalSnowAmount": 0, + "probOfPrecipitation": 59 + }, + { + "time": "2026-02-07T17:00Z", + "screenTemperature": 9.99, + "maxScreenAirTemp": 10.22, + "minScreenAirTemp": 9.98, + "screenDewPointTemperature": 8.13, + "feelsLikeTemperature": 8.22, + "windSpeed10m": 3.51, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 8.31, + "max10mWindGust": 9.11, + "visibility": 11604, + "screenRelativeHumidity": 88.07, + "mslp": 99680, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-07T18:00Z", + "screenTemperature": 9.89, + "maxScreenAirTemp": 9.99, + "minScreenAirTemp": 9.84, + "screenDewPointTemperature": 8.13, + "feelsLikeTemperature": 8.07, + "windSpeed10m": 3.54, + "windDirectionFrom10m": 181, + "windGustSpeed10m": 8.86, + "max10mWindGust": 9.03, + "visibility": 11879, + "screenRelativeHumidity": 88.72, + "mslp": 99760, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-07T19:00Z", + "screenTemperature": 9.68, + "maxScreenAirTemp": 9.89, + "minScreenAirTemp": 9.67, + "screenDewPointTemperature": 8.06, + "feelsLikeTemperature": 7.86, + "windSpeed10m": 3.45, + "windDirectionFrom10m": 183, + "windGustSpeed10m": 8.57, + "max10mWindGust": 8.86, + "visibility": 12104, + "screenRelativeHumidity": 89.57, + "mslp": 99816, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-07T20:00Z", + "screenTemperature": 9.59, + "maxScreenAirTemp": 9.68, + "minScreenAirTemp": 9.57, + "screenDewPointTemperature": 8.02, + "feelsLikeTemperature": 7.96, + "windSpeed10m": 3.08, + "windDirectionFrom10m": 179, + "windGustSpeed10m": 8.15, + "max10mWindGust": 8.88, + "visibility": 12574, + "screenRelativeHumidity": 89.91, + "mslp": 99876, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 4 + }, + { + "time": "2026-02-07T21:00Z", + "screenTemperature": 9.34, + "maxScreenAirTemp": 9.59, + "minScreenAirTemp": 9.34, + "screenDewPointTemperature": 8.01, + "feelsLikeTemperature": 7.65, + "windSpeed10m": 3.12, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 7.95, + "max10mWindGust": 8.46, + "visibility": 12829, + "screenRelativeHumidity": 91.36, + "mslp": 99932, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 3 + }, + { + "time": "2026-02-07T22:00Z", + "screenTemperature": 9.0, + "maxScreenAirTemp": 9.34, + "minScreenAirTemp": 8.98, + "screenDewPointTemperature": 7.71, + "feelsLikeTemperature": 7.27, + "windSpeed10m": 3.08, + "windDirectionFrom10m": 177, + "windGustSpeed10m": 8.34, + "max10mWindGust": 8.76, + "visibility": 12923, + "screenRelativeHumidity": 91.6, + "mslp": 99986, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-07T23:00Z", + "screenTemperature": 8.74, + "maxScreenAirTemp": 8.98, + "minScreenAirTemp": 8.71, + "screenDewPointTemperature": 7.57, + "feelsLikeTemperature": 7.09, + "windSpeed10m": 2.86, + "windDirectionFrom10m": 177, + "windGustSpeed10m": 7.68, + "max10mWindGust": 8.78, + "visibility": 14190, + "screenRelativeHumidity": 92.32, + "mslp": 100056, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-08T00:00Z", + "screenTemperature": 8.56, + "maxScreenAirTemp": 8.74, + "minScreenAirTemp": 8.56, + "screenDewPointTemperature": 7.59, + "feelsLikeTemperature": 7.12, + "windSpeed10m": 2.52, + "windDirectionFrom10m": 184, + "windGustSpeed10m": 7.13, + "max10mWindGust": 8.49, + "visibility": 13732, + "screenRelativeHumidity": 93.62, + "mslp": 100096, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-08T01:00Z", + "screenTemperature": 8.4, + "maxScreenAirTemp": 8.56, + "minScreenAirTemp": 8.38, + "screenDewPointTemperature": 7.27, + "feelsLikeTemperature": 7.08, + "windSpeed10m": 2.32, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 6.73, + "max10mWindGust": 7.62, + "visibility": 14599, + "screenRelativeHumidity": 92.57, + "mslp": 100150, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-08T02:00Z", + "screenTemperature": 8.14, + "maxScreenAirTemp": 8.4, + "minScreenAirTemp": 8.13, + "screenDewPointTemperature": 7.17, + "feelsLikeTemperature": 7.11, + "windSpeed10m": 1.93, + "windDirectionFrom10m": 191, + "windGustSpeed10m": 5.96, + "max10mWindGust": 7.23, + "visibility": 12665, + "screenRelativeHumidity": 93.62, + "mslp": 100190, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T03:00Z", + "screenTemperature": 7.9, + "maxScreenAirTemp": 8.14, + "minScreenAirTemp": 7.89, + "screenDewPointTemperature": 7.12, + "feelsLikeTemperature": 7.1, + "windSpeed10m": 1.63, + "windDirectionFrom10m": 195, + "windGustSpeed10m": 5.28, + "max10mWindGust": 6.22, + "visibility": 10018, + "screenRelativeHumidity": 94.84, + "mslp": 100224, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T04:00Z", + "screenTemperature": 7.78, + "maxScreenAirTemp": 7.9, + "minScreenAirTemp": 7.76, + "screenDewPointTemperature": 7.07, + "feelsLikeTemperature": 6.86, + "windSpeed10m": 1.74, + "windDirectionFrom10m": 188, + "windGustSpeed10m": 5.13, + "max10mWindGust": 5.76, + "visibility": 8777, + "screenRelativeHumidity": 95.25, + "mslp": 100253, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T05:00Z", + "screenTemperature": 7.67, + "maxScreenAirTemp": 7.78, + "minScreenAirTemp": 7.62, + "screenDewPointTemperature": 7.02, + "feelsLikeTemperature": 6.77, + "windSpeed10m": 1.64, + "windDirectionFrom10m": 177, + "windGustSpeed10m": 5.17, + "max10mWindGust": 5.88, + "visibility": 7296, + "screenRelativeHumidity": 95.73, + "mslp": 100280, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T06:00Z", + "screenTemperature": 7.52, + "maxScreenAirTemp": 7.67, + "minScreenAirTemp": 7.47, + "screenDewPointTemperature": 6.7, + "feelsLikeTemperature": 6.68, + "windSpeed10m": 1.6, + "windDirectionFrom10m": 183, + "windGustSpeed10m": 4.97, + "max10mWindGust": 5.64, + "visibility": 7420, + "screenRelativeHumidity": 94.66, + "mslp": 100327, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T07:00Z", + "screenTemperature": 7.63, + "maxScreenAirTemp": 7.64, + "minScreenAirTemp": 7.52, + "screenDewPointTemperature": 6.82, + "feelsLikeTemperature": 6.29, + "windSpeed10m": 2.18, + "windDirectionFrom10m": 182, + "windGustSpeed10m": 5.54, + "max10mWindGust": 6.01, + "visibility": 7504, + "screenRelativeHumidity": 94.7, + "mslp": 100390, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T08:00Z", + "screenTemperature": 7.81, + "maxScreenAirTemp": 7.81, + "minScreenAirTemp": 7.63, + "screenDewPointTemperature": 7.06, + "feelsLikeTemperature": 6.72, + "windSpeed10m": 1.93, + "windDirectionFrom10m": 190, + "windGustSpeed10m": 4.93, + "max10mWindGust": 5.86, + "visibility": 6197, + "screenRelativeHumidity": 95.08, + "mslp": 100450, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 9 + }, + { + "time": "2026-02-08T09:00Z", + "screenTemperature": 8.12, + "maxScreenAirTemp": 8.13, + "minScreenAirTemp": 7.81, + "screenDewPointTemperature": 7.2, + "feelsLikeTemperature": 7.05, + "windSpeed10m": 1.95, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 4.53, + "max10mWindGust": 4.92, + "visibility": 6327, + "screenRelativeHumidity": 94.03, + "mslp": 100503, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T10:00Z", + "screenTemperature": 8.86, + "maxScreenAirTemp": 8.86, + "minScreenAirTemp": 8.12, + "screenDewPointTemperature": 7.54, + "feelsLikeTemperature": 7.73, + "windSpeed10m": 2.17, + "windDirectionFrom10m": 176, + "windGustSpeed10m": 4.42, + "max10mWindGust": 4.54, + "visibility": 7222, + "screenRelativeHumidity": 91.55, + "mslp": 100533, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T11:00Z", + "screenTemperature": 9.57, + "maxScreenAirTemp": 9.57, + "minScreenAirTemp": 8.86, + "screenDewPointTemperature": 7.57, + "feelsLikeTemperature": 8.41, + "windSpeed10m": 2.37, + "windDirectionFrom10m": 181, + "windGustSpeed10m": 4.88, + "max10mWindGust": 4.88, + "visibility": 10651, + "screenRelativeHumidity": 87.5, + "mslp": 100560, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-08T12:00Z", + "screenTemperature": 10.27, + "maxScreenAirTemp": 10.28, + "minScreenAirTemp": 9.57, + "screenDewPointTemperature": 7.41, + "feelsLikeTemperature": 9.29, + "windSpeed10m": 2.28, + "windDirectionFrom10m": 185, + "windGustSpeed10m": 4.71, + "max10mWindGust": 4.71, + "visibility": 12395, + "screenRelativeHumidity": 82.51, + "mslp": 100560, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-08T13:00Z", + "screenTemperature": 10.75, + "maxScreenAirTemp": 10.76, + "minScreenAirTemp": 10.27, + "screenDewPointTemperature": 6.87, + "feelsLikeTemperature": 9.48, + "windSpeed10m": 2.77, + "windDirectionFrom10m": 184, + "windGustSpeed10m": 5.56, + "max10mWindGust": 5.56, + "visibility": 14708, + "screenRelativeHumidity": 76.97, + "mslp": 100530, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-08T14:00Z", + "screenTemperature": 10.84, + "maxScreenAirTemp": 10.88, + "minScreenAirTemp": 10.75, + "screenDewPointTemperature": 6.71, + "feelsLikeTemperature": 9.4, + "windSpeed10m": 3.1, + "windDirectionFrom10m": 186, + "windGustSpeed10m": 6.12, + "max10mWindGust": 6.29, + "visibility": 16685, + "screenRelativeHumidity": 75.74, + "mslp": 100530, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-08T15:00Z", + "screenTemperature": 10.76, + "maxScreenAirTemp": 10.84, + "minScreenAirTemp": 10.73, + "screenDewPointTemperature": 6.67, + "feelsLikeTemperature": 9.29, + "windSpeed10m": 3.11, + "windDirectionFrom10m": 182, + "windGustSpeed10m": 6.07, + "max10mWindGust": 6.26, + "visibility": 16963, + "screenRelativeHumidity": 75.87, + "mslp": 100527, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-08T16:00Z", + "screenTemperature": 10.36, + "maxScreenAirTemp": 10.76, + "minScreenAirTemp": 10.33, + "screenDewPointTemperature": 6.66, + "feelsLikeTemperature": 8.88, + "windSpeed10m": 3.07, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 5.99, + "max10mWindGust": 6.33, + "visibility": 17519, + "screenRelativeHumidity": 77.89, + "mslp": 100530, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-08T17:00Z", + "screenTemperature": 9.94, + "maxScreenAirTemp": 10.36, + "minScreenAirTemp": 9.93, + "screenDewPointTemperature": 6.86, + "feelsLikeTemperature": 8.52, + "windSpeed10m": 2.84, + "windDirectionFrom10m": 179, + "windGustSpeed10m": 5.58, + "max10mWindGust": 6.05, + "visibility": 16071, + "screenRelativeHumidity": 81.23, + "mslp": 100550, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T18:00Z", + "screenTemperature": 9.55, + "maxScreenAirTemp": 9.94, + "minScreenAirTemp": 9.54, + "screenDewPointTemperature": 6.97, + "feelsLikeTemperature": 8.1, + "windSpeed10m": 2.81, + "windDirectionFrom10m": 176, + "windGustSpeed10m": 5.68, + "max10mWindGust": 6.14, + "visibility": 15755, + "screenRelativeHumidity": 83.93, + "mslp": 100560, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 12 + }, + { + "time": "2026-02-08T19:00Z", + "screenTemperature": 9.23, + "maxScreenAirTemp": 9.55, + "minScreenAirTemp": 9.22, + "screenDewPointTemperature": 7.05, + "feelsLikeTemperature": 7.71, + "windSpeed10m": 2.83, + "windDirectionFrom10m": 168, + "windGustSpeed10m": 5.67, + "max10mWindGust": 6.27, + "visibility": 14548, + "screenRelativeHumidity": 86.32, + "mslp": 100547, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 18 + }, + { + "time": "2026-02-08T20:00Z", + "screenTemperature": 9.05, + "maxScreenAirTemp": 9.23, + "minScreenAirTemp": 9.04, + "screenDewPointTemperature": 7.13, + "feelsLikeTemperature": 7.66, + "windSpeed10m": 2.57, + "windDirectionFrom10m": 173, + "windGustSpeed10m": 5.24, + "max10mWindGust": 6.08, + "visibility": 13961, + "screenRelativeHumidity": 87.79, + "mslp": 100547, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 17 + }, + { + "time": "2026-02-08T21:00Z", + "screenTemperature": 8.81, + "maxScreenAirTemp": 9.05, + "minScreenAirTemp": 8.81, + "screenDewPointTemperature": 7.2, + "feelsLikeTemperature": 7.4, + "windSpeed10m": 2.56, + "windDirectionFrom10m": 163, + "windGustSpeed10m": 5.38, + "max10mWindGust": 5.73, + "visibility": 13739, + "screenRelativeHumidity": 89.7, + "mslp": 100540, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.07, + "totalPrecipAmount": 0.2, + "totalSnowAmount": 0, + "probOfPrecipitation": 45 + }, + { + "time": "2026-02-08T22:00Z", + "screenTemperature": 8.74, + "maxScreenAirTemp": 8.81, + "minScreenAirTemp": 8.72, + "screenDewPointTemperature": 7.12, + "feelsLikeTemperature": 7.36, + "windSpeed10m": 2.47, + "windDirectionFrom10m": 164, + "windGustSpeed10m": 5.43, + "max10mWindGust": 5.67, + "visibility": 11395, + "screenRelativeHumidity": 89.66, + "mslp": 100530, + "uvIndex": 0, + "significantWeatherCode": 9, + "precipitationRate": 0.23, + "totalPrecipAmount": 0.18, + "totalSnowAmount": 0, + "probOfPrecipitation": 44 + }, + { + "time": "2026-02-08T23:00Z", + "screenTemperature": 8.57, + "maxScreenAirTemp": 8.74, + "minScreenAirTemp": 8.53, + "screenDewPointTemperature": 7.23, + "feelsLikeTemperature": 7.31, + "windSpeed10m": 2.28, + "windDirectionFrom10m": 149, + "windGustSpeed10m": 5.28, + "max10mWindGust": 5.87, + "visibility": 10051, + "screenRelativeHumidity": 91.35, + "mslp": 100497, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.22, + "totalPrecipAmount": 0.26, + "totalSnowAmount": 0, + "probOfPrecipitation": 54 + }, + { + "time": "2026-02-09T00:00Z", + "screenTemperature": 8.52, + "maxScreenAirTemp": 8.57, + "minScreenAirTemp": 8.49, + "screenDewPointTemperature": 7.17, + "feelsLikeTemperature": 7.21, + "windSpeed10m": 2.32, + "windDirectionFrom10m": 151, + "windGustSpeed10m": 5.44, + "max10mWindGust": 5.96, + "visibility": 13108, + "screenRelativeHumidity": 91.42, + "mslp": 100475, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 13 + }, + { + "time": "2026-02-09T01:00Z", + "screenTemperature": 8.39, + "maxScreenAirTemp": 8.52, + "minScreenAirTemp": 8.36, + "screenDewPointTemperature": 7.08, + "feelsLikeTemperature": 6.94, + "windSpeed10m": 2.49, + "windDirectionFrom10m": 157, + "windGustSpeed10m": 5.83, + "max10mWindGust": 6.54, + "visibility": 14678, + "screenRelativeHumidity": 91.55, + "mslp": 100430, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 12 + }, + { + "time": "2026-02-09T02:00Z", + "screenTemperature": 8.23, + "maxScreenAirTemp": 8.39, + "minScreenAirTemp": 8.18, + "screenDewPointTemperature": 6.88, + "feelsLikeTemperature": 6.86, + "windSpeed10m": 2.34, + "windDirectionFrom10m": 155, + "windGustSpeed10m": 5.35, + "max10mWindGust": 6.7, + "visibility": 13081, + "screenRelativeHumidity": 91.35, + "mslp": 100385, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 16 + }, + { + "time": "2026-02-09T03:00Z", + "screenTemperature": 8.1, + "maxScreenAirTemp": 8.23, + "minScreenAirTemp": 8.05, + "screenDewPointTemperature": 6.78, + "feelsLikeTemperature": 6.67, + "windSpeed10m": 2.37, + "windDirectionFrom10m": 150, + "windGustSpeed10m": 5.35, + "max10mWindGust": 6.67, + "visibility": 15140, + "screenRelativeHumidity": 91.56, + "mslp": 100335, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-09T04:00Z", + "screenTemperature": 7.9, + "maxScreenAirTemp": 8.1, + "minScreenAirTemp": 7.86, + "screenDewPointTemperature": 6.58, + "feelsLikeTemperature": 6.41, + "windSpeed10m": 2.39, + "windDirectionFrom10m": 149, + "windGustSpeed10m": 5.43, + "max10mWindGust": 6.53, + "visibility": 15366, + "screenRelativeHumidity": 91.65, + "mslp": 100305, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-09T05:00Z", + "screenTemperature": 7.71, + "maxScreenAirTemp": 7.9, + "minScreenAirTemp": 7.65, + "screenDewPointTemperature": 6.51, + "feelsLikeTemperature": 6.28, + "windSpeed10m": 2.3, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.3, + "max10mWindGust": 6.91, + "visibility": 14570, + "screenRelativeHumidity": 92.33, + "mslp": 100283, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T06:00Z", + "screenTemperature": 7.56, + "maxScreenAirTemp": 7.71, + "minScreenAirTemp": 7.54, + "screenDewPointTemperature": 6.38, + "feelsLikeTemperature": 6.11, + "windSpeed10m": 2.29, + "windDirectionFrom10m": 148, + "windGustSpeed10m": 5.34, + "max10mWindGust": 6.56, + "visibility": 13685, + "screenRelativeHumidity": 92.49, + "mslp": 100280, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T07:00Z", + "screenTemperature": 7.61, + "maxScreenAirTemp": 7.62, + "minScreenAirTemp": 7.56, + "screenDewPointTemperature": 6.43, + "feelsLikeTemperature": 6.17, + "windSpeed10m": 2.28, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.26, + "max10mWindGust": 6.34, + "visibility": 13185, + "screenRelativeHumidity": 92.48, + "mslp": 100282, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-09T08:00Z", + "screenTemperature": 7.7, + "maxScreenAirTemp": 7.75, + "minScreenAirTemp": 7.61, + "screenDewPointTemperature": 6.48, + "feelsLikeTemperature": 6.25, + "windSpeed10m": 2.34, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.57, + "max10mWindGust": 5.76, + "visibility": 13541, + "screenRelativeHumidity": 92.21, + "mslp": 100275, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T09:00Z", + "screenTemperature": 7.92, + "maxScreenAirTemp": 7.92, + "minScreenAirTemp": 7.7, + "screenDewPointTemperature": 6.53, + "feelsLikeTemperature": 6.42, + "windSpeed10m": 2.43, + "windDirectionFrom10m": 142, + "windGustSpeed10m": 5.54, + "max10mWindGust": 6.4, + "visibility": 13747, + "screenRelativeHumidity": 91.19, + "mslp": 100275, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T10:00Z", + "screenTemperature": 8.6, + "maxScreenAirTemp": 8.65, + "minScreenAirTemp": 7.92, + "screenDewPointTemperature": 6.57, + "feelsLikeTemperature": 7.09, + "windSpeed10m": 2.66, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.71, + "max10mWindGust": 5.71, + "visibility": 14552, + "screenRelativeHumidity": 87.48, + "mslp": 100241, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T11:00Z", + "screenTemperature": 9.43, + "maxScreenAirTemp": 9.43, + "minScreenAirTemp": 8.6, + "screenDewPointTemperature": 6.49, + "feelsLikeTemperature": 7.83, + "windSpeed10m": 3.0, + "windDirectionFrom10m": 151, + "windGustSpeed10m": 6.25, + "max10mWindGust": 6.25, + "visibility": 19055, + "screenRelativeHumidity": 82.28, + "mslp": 100209, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T12:00Z", + "screenTemperature": 10.25, + "screenDewPointTemperature": 6.37, + "feelsLikeTemperature": 8.61, + "windSpeed10m": 3.28, + "windDirectionFrom10m": 155, + "windGustSpeed10m": 6.87, + "visibility": 20517, + "screenRelativeHumidity": 77.18, + "mslp": 100150, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "probOfPrecipitation": 6 + } + ] + } + } + ], + "parameters": [ + { + "totalSnowAmount": { "type": "Parameter", "description": "Total Snow Amount Over Previous Hour", "unit": { "label": "millimetres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm" } } }, + "screenTemperature": { "type": "Parameter", "description": "Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "visibility": { "type": "Parameter", "description": "Visibility", "unit": { "label": "metres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m" } } }, + "windDirectionFrom10m": { "type": "Parameter", "description": "10m Wind From Direction", "unit": { "label": "degrees", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "deg" } } }, + "precipitationRate": { "type": "Parameter", "description": "Precipitation Rate", "unit": { "label": "millimetres per hour", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm/h" } } }, + "maxScreenAirTemp": { "type": "Parameter", "description": "Maximum Screen Air Temperature Over Previous Hour", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "feelsLikeTemperature": { "type": "Parameter", "description": "Feels Like Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "screenDewPointTemperature": { "type": "Parameter", "description": "Screen Dew Point Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "screenRelativeHumidity": { "type": "Parameter", "description": "Screen Relative Humidity", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "windSpeed10m": { "type": "Parameter", "description": "10m Wind Speed", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "probOfPrecipitation": { "type": "Parameter", "description": "Probability of Precipitation", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "max10mWindGust": { "type": "Parameter", "description": "Maximum 10m Wind Gust Speed Over Previous Hour", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "significantWeatherCode": { "type": "Parameter", "description": "Significant Weather Code", "unit": { "label": "dimensionless", "symbol": { "value": "https://datahub.metoffice.gov.uk/", "type": "1" } } }, + "minScreenAirTemp": { "type": "Parameter", "description": "Minimum Screen Air Temperature Over Previous Hour", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "totalPrecipAmount": { "type": "Parameter", "description": "Total Precipitation Amount Over Previous Hour", "unit": { "label": "millimetres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm" } } }, + "mslp": { "type": "Parameter", "description": "Mean Sea Level Pressure", "unit": { "label": "pascals", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Pa" } } }, + "windGustSpeed10m": { "type": "Parameter", "description": "10m Wind Gust Speed", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "uvIndex": { "type": "Parameter", "description": "UV Index", "unit": { "label": "dimensionless", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "1" } } } + } + ] +} diff --git a/tests/mocks/weather_ukmetoffice_daily.json b/tests/mocks/weather_ukmetoffice_daily.json new file mode 100644 index 0000000000..e774a0f575 --- /dev/null +++ b/tests/mocks/weather_ukmetoffice_daily.json @@ -0,0 +1,419 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-0.12480000000000001, 51.5081, 11.0] }, + "properties": { + "location": { "name": "London" }, + "requestPointDistance": 221.7807, + "modelRunDate": "2026-02-07T12:00Z", + "timeSeries": [ + { + "time": "2026-02-06T00:00Z", + "midday10MWindSpeed": 0.82, + "midnight10MWindSpeed": 1.59, + "midday10MWindDirection": 121, + "midnight10MWindDirection": 175, + "midday10MWindGust": 3.09, + "midnight10MWindGust": 7.72, + "middayVisibility": 4000, + "midnightVisibility": 12560, + "middayRelativeHumidity": 92.93, + "midnightRelativeHumidity": 89.85, + "middayMslp": 98480, + "midnightMslp": 99260, + "nightSignificantWeatherCode": 2, + "dayMaxScreenTemperature": 11.37, + "nightMinScreenTemperature": 7.26, + "dayUpperBoundMaxTemp": 12.53, + "nightUpperBoundMinTemp": 8.77, + "dayLowerBoundMaxTemp": 9.86, + "nightLowerBoundMinTemp": 6.62, + "nightMinFeelsLikeTemp": 5.98, + "dayUpperBoundMaxFeelsLikeTemp": 11.58, + "nightUpperBoundMinFeelsLikeTemp": 6.83, + "dayLowerBoundMaxFeelsLikeTemp": 8.89, + "nightLowerBoundMinFeelsLikeTemp": 5.23, + "nightProbabilityOfPrecipitation": 85, + "nightProbabilityOfSnow": 0, + "nightProbabilityOfHeavySnow": 0, + "nightProbabilityOfRain": 85, + "nightProbabilityOfHeavyRain": 80, + "nightProbabilityOfHail": 16, + "nightProbabilityOfSferics": 8 + }, + { + "time": "2026-02-07T00:00Z", + "midday10MWindSpeed": 1.9, + "midnight10MWindSpeed": 2.52, + "midday10MWindDirection": 165, + "midnight10MWindDirection": 184, + "midday10MWindGust": 7.72, + "midnight10MWindGust": 7.13, + "middayVisibility": 8550, + "midnightVisibility": 13732, + "middayRelativeHumidity": 93.08, + "midnightRelativeHumidity": 93.62, + "middayMslp": 99440, + "midnightMslp": 100100, + "maxUvIndex": 1, + "daySignificantWeatherCode": 12, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 10.5, + "nightMinScreenTemperature": 7.52, + "dayUpperBoundMaxTemp": 11.72, + "nightUpperBoundMinTemp": 9.39, + "dayLowerBoundMaxTemp": 9.78, + "nightLowerBoundMinTemp": 5.83, + "dayMaxFeelsLikeTemp": 8.76, + "nightMinFeelsLikeTemp": 6.29, + "dayUpperBoundMaxFeelsLikeTemp": 9.47, + "nightUpperBoundMinFeelsLikeTemp": 8.28, + "dayLowerBoundMaxFeelsLikeTemp": 8.06, + "nightLowerBoundMinFeelsLikeTemp": 5.22, + "dayProbabilityOfPrecipitation": 91, + "nightProbabilityOfPrecipitation": 10, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 91, + "nightProbabilityOfRain": 10, + "dayProbabilityOfHeavyRain": 86, + "nightProbabilityOfHeavyRain": 2, + "dayProbabilityOfHail": 17, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 11, + "nightProbabilityOfSferics": 0 + }, + { + "time": "2026-02-08T00:00Z", + "midday10MWindSpeed": 2.28, + "midnight10MWindSpeed": 2.32, + "midday10MWindDirection": 185, + "midnight10MWindDirection": 151, + "midday10MWindGust": 4.71, + "midnight10MWindGust": 5.44, + "middayVisibility": 12395, + "midnightVisibility": 13108, + "middayRelativeHumidity": 82.51, + "midnightRelativeHumidity": 91.42, + "middayMslp": 100559, + "midnightMslp": 100474, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 11.07, + "nightMinScreenTemperature": 7.56, + "dayUpperBoundMaxTemp": 11.84, + "nightUpperBoundMinTemp": 8.59, + "dayLowerBoundMaxTemp": 9.76, + "nightLowerBoundMinTemp": 5.18, + "dayMaxFeelsLikeTemp": 9.48, + "nightMinFeelsLikeTemp": 6.11, + "dayUpperBoundMaxFeelsLikeTemp": 10.97, + "nightUpperBoundMinFeelsLikeTemp": 7.32, + "dayLowerBoundMaxFeelsLikeTemp": 8.13, + "nightLowerBoundMinFeelsLikeTemp": 5.38, + "dayProbabilityOfPrecipitation": 10, + "nightProbabilityOfPrecipitation": 54, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 10, + "nightProbabilityOfRain": 54, + "dayProbabilityOfHeavyRain": 2, + "nightProbabilityOfHeavyRain": 32, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 4, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 6 + }, + { + "time": "2026-02-09T00:00Z", + "midday10MWindSpeed": 3.28, + "midnight10MWindSpeed": 3.42, + "midday10MWindDirection": 155, + "midnight10MWindDirection": 121, + "midday10MWindGust": 6.87, + "midnight10MWindGust": 7.02, + "middayVisibility": 20517, + "midnightVisibility": 18708, + "middayRelativeHumidity": 77.18, + "midnightRelativeHumidity": 86.28, + "middayMslp": 100150, + "midnightMslp": 99580, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 10.89, + "nightMinScreenTemperature": 6.93, + "dayUpperBoundMaxTemp": 11.87, + "nightUpperBoundMinTemp": 8.61, + "dayLowerBoundMaxTemp": 8.55, + "nightLowerBoundMinTemp": 4.78, + "dayMaxFeelsLikeTemp": 9.06, + "nightMinFeelsLikeTemp": 5.13, + "dayUpperBoundMaxFeelsLikeTemp": 9.87, + "nightUpperBoundMinFeelsLikeTemp": 6.29, + "dayLowerBoundMaxFeelsLikeTemp": 6.57, + "nightLowerBoundMinFeelsLikeTemp": 3.3, + "dayProbabilityOfPrecipitation": 6, + "nightProbabilityOfPrecipitation": 18, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 6, + "nightProbabilityOfRain": 18, + "dayProbabilityOfHeavyRain": 1, + "nightProbabilityOfHeavyRain": 7, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 0 + }, + { + "time": "2026-02-10T00:00Z", + "midday10MWindSpeed": 3.09, + "midnight10MWindSpeed": 3.12, + "midday10MWindDirection": 150, + "midnight10MWindDirection": 191, + "midday10MWindGust": 6.52, + "midnight10MWindGust": 6.18, + "middayVisibility": 17148, + "midnightVisibility": 12750, + "middayRelativeHumidity": 86.68, + "midnightRelativeHumidity": 93.78, + "middayMslp": 98991, + "midnightMslp": 98238, + "maxUvIndex": 1, + "daySignificantWeatherCode": 8, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 10.47, + "nightMinScreenTemperature": 8.75, + "dayUpperBoundMaxTemp": 13.15, + "nightUpperBoundMinTemp": 10.63, + "dayLowerBoundMaxTemp": 7.91, + "nightLowerBoundMinTemp": 6.14, + "dayMaxFeelsLikeTemp": 8.44, + "nightMinFeelsLikeTemp": 7.65, + "dayUpperBoundMaxFeelsLikeTemp": 11.11, + "nightUpperBoundMinFeelsLikeTemp": 8.73, + "dayLowerBoundMaxFeelsLikeTemp": 6.87, + "nightLowerBoundMinFeelsLikeTemp": 5.59, + "dayProbabilityOfPrecipitation": 49, + "nightProbabilityOfPrecipitation": 54, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 49, + "nightProbabilityOfRain": 54, + "dayProbabilityOfHeavyRain": 26, + "nightProbabilityOfHeavyRain": 32, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 2, + "dayProbabilityOfSferics": 1, + "nightProbabilityOfSferics": 2 + }, + { + "time": "2026-02-11T00:00Z", + "midday10MWindSpeed": 4.2, + "midnight10MWindSpeed": 3.4, + "midday10MWindDirection": 228, + "midnight10MWindDirection": 241, + "midday10MWindGust": 9.23, + "midnight10MWindGust": 6.88, + "middayVisibility": 20709, + "midnightVisibility": 18608, + "middayRelativeHumidity": 82.88, + "midnightRelativeHumidity": 89.7, + "middayMslp": 98098, + "midnightMslp": 97870, + "maxUvIndex": 1, + "daySignificantWeatherCode": 12, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 11.71, + "nightMinScreenTemperature": 7.6, + "dayUpperBoundMaxTemp": 13.23, + "nightUpperBoundMinTemp": 9.77, + "dayLowerBoundMaxTemp": 7.85, + "nightLowerBoundMinTemp": 4.71, + "dayMaxFeelsLikeTemp": 9.43, + "nightMinFeelsLikeTemp": 5.5, + "dayUpperBoundMaxFeelsLikeTemp": 11.39, + "nightUpperBoundMinFeelsLikeTemp": 7.6, + "dayLowerBoundMaxFeelsLikeTemp": 8.0, + "nightLowerBoundMinFeelsLikeTemp": 4.33, + "dayProbabilityOfPrecipitation": 50, + "nightProbabilityOfPrecipitation": 46, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 50, + "nightProbabilityOfRain": 46, + "dayProbabilityOfHeavyRain": 28, + "nightProbabilityOfHeavyRain": 25, + "dayProbabilityOfHail": 2, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 5, + "nightProbabilityOfSferics": 4 + }, + { + "time": "2026-02-12T00:00Z", + "midday10MWindSpeed": 3.99, + "midnight10MWindSpeed": 3.62, + "midday10MWindDirection": 297, + "midnight10MWindDirection": 321, + "midday10MWindGust": 8.71, + "midnight10MWindGust": 7.52, + "middayVisibility": 21894, + "midnightVisibility": 24612, + "middayRelativeHumidity": 80.41, + "midnightRelativeHumidity": 83.6, + "middayMslp": 98255, + "midnightMslp": 98981, + "maxUvIndex": 1, + "daySignificantWeatherCode": 8, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 9.92, + "nightMinScreenTemperature": 3.15, + "dayUpperBoundMaxTemp": 12.14, + "nightUpperBoundMinTemp": 9.05, + "dayLowerBoundMaxTemp": 5.26, + "nightLowerBoundMinTemp": 0.41, + "dayMaxFeelsLikeTemp": 6.69, + "nightMinFeelsLikeTemp": -0.03, + "dayUpperBoundMaxFeelsLikeTemp": 10.16, + "nightUpperBoundMinFeelsLikeTemp": 7.3, + "dayLowerBoundMaxFeelsLikeTemp": 4.45, + "nightLowerBoundMinFeelsLikeTemp": -3.64, + "dayProbabilityOfPrecipitation": 21, + "nightProbabilityOfPrecipitation": 22, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 2, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 1, + "dayProbabilityOfRain": 21, + "nightProbabilityOfRain": 22, + "dayProbabilityOfHeavyRain": 9, + "nightProbabilityOfHeavyRain": 10, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 2, + "nightProbabilityOfSferics": 2 + }, + { + "time": "2026-02-13T00:00Z", + "midday10MWindSpeed": 4.14, + "midnight10MWindSpeed": 2.83, + "midday10MWindDirection": 322, + "midnight10MWindDirection": 307, + "midday10MWindGust": 9.4, + "midnight10MWindGust": 5.92, + "middayVisibility": 34312, + "midnightVisibility": 34597, + "middayRelativeHumidity": 66.26, + "midnightRelativeHumidity": 77.93, + "middayMslp": 99718, + "midnightMslp": 100243, + "maxUvIndex": 1, + "daySignificantWeatherCode": 1, + "nightSignificantWeatherCode": 0, + "dayMaxScreenTemperature": 6.79, + "nightMinScreenTemperature": 0.85, + "dayUpperBoundMaxTemp": 11.07, + "nightUpperBoundMinTemp": 7.3, + "dayLowerBoundMaxTemp": 2.84, + "nightLowerBoundMinTemp": -1.75, + "dayMaxFeelsLikeTemp": 2.21, + "nightMinFeelsLikeTemp": -2.05, + "dayUpperBoundMaxFeelsLikeTemp": 9.57, + "nightUpperBoundMinFeelsLikeTemp": 6.72, + "dayLowerBoundMaxFeelsLikeTemp": -0.29, + "nightLowerBoundMinFeelsLikeTemp": -3.77, + "dayProbabilityOfPrecipitation": 11, + "nightProbabilityOfPrecipitation": 6, + "dayProbabilityOfSnow": 2, + "nightProbabilityOfSnow": 1, + "dayProbabilityOfHeavySnow": 1, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 9, + "nightProbabilityOfRain": 6, + "dayProbabilityOfHeavyRain": 4, + "nightProbabilityOfHeavyRain": 3, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 1, + "nightProbabilityOfSferics": 0 + } + ] + } + } + ], + "parameters": [ + { + "daySignificantWeatherCode": { "type": "Parameter", "description": "Day Significant Weather Code", "unit": { "label": "dimensionless", "symbol": { "value": "https://datahub.metoffice.gov.uk/", "type": "1" } } }, + "midnightRelativeHumidity": { "type": "Parameter", "description": "Relative Humidity at Local Midnight", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightProbabilityOfHeavyRain": { "type": "Parameter", "description": "Probability of Heavy Rain During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "midnight10MWindSpeed": { "type": "Parameter", "description": "10m Wind Speed at Local Midnight", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "nightUpperBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Night Minimum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "nightUpperBoundMinTemp": { "type": "Parameter", "description": "Upper Bound on Night Minimum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "midnightVisibility": { "type": "Parameter", "description": "Visibility at Local Midnight", "unit": { "label": "metres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m" } } }, + "dayUpperBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Day Maximum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "nightProbabilityOfRain": { "type": "Parameter", "description": "Probability of Rain During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "midday10MWindDirection": { "type": "Parameter", "description": "10m Wind Direction at Local Midday", "unit": { "label": "degrees", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "deg" } } }, + "nightLowerBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Night Minimum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "nightProbabilityOfHail": { "type": "Parameter", "description": "Probability of Hail During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "middayMslp": { "type": "Parameter", "description": "Mean Sea Level Pressure at Local Midday", "unit": { "label": "pascals", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Pa" } } }, + "dayProbabilityOfHeavySnow": { "type": "Parameter", "description": "Probability of Heavy Snow During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightProbabilityOfPrecipitation": { "type": "Parameter", "description": "Probability of Precipitation During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayProbabilityOfHail": { "type": "Parameter", "description": "Probability of Hail During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayProbabilityOfRain": { "type": "Parameter", "description": "Probability of Rain During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "midday10MWindSpeed": { "type": "Parameter", "description": "10m Wind Speed at Local Midday", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "midday10MWindGust": { "type": "Parameter", "description": "10m Wind Gust Speed at Local Midday", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "middayVisibility": { "type": "Parameter", "description": "Visibility at Local Midday", "unit": { "label": "metres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m" } } }, + "midnight10MWindGust": { "type": "Parameter", "description": "10m Wind Gust Speed at Local Midnight", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "midnightMslp": { "type": "Parameter", "description": "Mean Sea Level Pressure at Local Midnight", "unit": { "label": "pascals", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Pa" } } }, + "dayProbabilityOfSferics": { "type": "Parameter", "description": "Probability of Sferics During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightSignificantWeatherCode": { "type": "Parameter", "description": "Night Significant Weather Code", "unit": { "label": "dimensionless", "symbol": { "value": "https://datahub.metoffice.gov.uk/", "type": "1" } } }, + "dayProbabilityOfPrecipitation": { "type": "Parameter", "description": "Probability of Precipitation During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayProbabilityOfHeavyRain": { "type": "Parameter", "description": "Probability of Heavy Rain During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayMaxScreenTemperature": { "type": "Parameter", "description": "Day Maximum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightMinScreenTemperature": { "type": "Parameter", "description": "Night Minimum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "midnight10MWindDirection": { "type": "Parameter", "description": "10m Wind Direction at Local Midnight", "unit": { "label": "degrees", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "deg" } } }, + "maxUvIndex": { "type": "Parameter", "description": "Day Maximum UV Index", "unit": { "label": "dimensionless", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "1" } } }, + "dayProbabilityOfSnow": { "type": "Parameter", "description": "Probability of Snow During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightProbabilityOfSnow": { "type": "Parameter", "description": "Probability of Snow During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayLowerBoundMaxTemp": { "type": "Parameter", "description": "Lower Bound on Day Maximum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightProbabilityOfHeavySnow": { "type": "Parameter", "description": "Probability of Heavy Snow During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayLowerBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Day Maximum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "dayUpperBoundMaxTemp": { "type": "Parameter", "description": "Upper Bound on Day Maximum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "dayMaxFeelsLikeTemp": { "type": "Parameter", "description": "Day Maximum Feels Like Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "middayRelativeHumidity": { "type": "Parameter", "description": "Relative Humidity at Local Midday", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightLowerBoundMinTemp": { "type": "Parameter", "description": "Lower Bound on Night Minimum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightMinFeelsLikeTemp": { "type": "Parameter", "description": "Night Minimum Feels Like Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightProbabilityOfSferics": { "type": "Parameter", "description": "Probability of Sferics During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } } + } + ] +} diff --git a/tests/mocks/weather_weatherbit.json b/tests/mocks/weather_weatherbit.json new file mode 100644 index 0000000000..bc8dfb530e --- /dev/null +++ b/tests/mocks/weather_weatherbit.json @@ -0,0 +1,45 @@ +{ + "count": 1, + "data": [ + { + "app_temp": -0.6, + "aqi": 44, + "city_name": "New York City", + "clouds": 100, + "country_code": "US", + "datetime": "2026-02-06:21", + "dewpt": -9, + "dhi": 62, + "dni": 555, + "elev_angle": 12.55, + "ghi": 175, + "gust": 3.1, + "h_angle": 60, + "lat": 40.7128, + "lon": -74.006, + "ob_time": "2026-02-06 21:25", + "pod": "d", + "precip": 0, + "pres": 1004, + "rh": 47, + "slp": 1004, + "snow": 0, + "solar_rad": 35, + "sources": ["KJRB", "radar", "satellite"], + "state_code": "NY", + "station": "KJRB", + "sunrise": "11:59", + "sunset": "22:21", + "temp": 1, + "timezone": "America/New_York", + "ts": 1770413100, + "uv": 0, + "vis": 16, + "weather": { "code": 804, "description": "Overcast clouds", "icon": "c04d" }, + "wind_cdir": "SSW", + "wind_cdir_full": "south-southwest", + "wind_dir": 210, + "wind_spd": 1.5 + } + ] +} diff --git a/tests/mocks/weather_weatherbit_forecast.json b/tests/mocks/weather_weatherbit_forecast.json new file mode 100644 index 0000000000..b5239b8436 --- /dev/null +++ b/tests/mocks/weather_weatherbit_forecast.json @@ -0,0 +1,290 @@ +{ + "city_name": "New York City", + "country_code": "US", + "data": [ + { + "app_max_temp": -2.7, + "app_min_temp": -9.8, + "clouds": 76, + "clouds_hi": 8, + "clouds_low": 40, + "clouds_mid": 90, + "datetime": "2026-02-06", + "dewpt": -7.6, + "high_temp": 0.8, + "low_temp": -6.5, + "max_dhi": null, + "max_temp": 0.5, + "min_temp": -6.3, + "moon_phase": 0.68, + "moon_phase_lunation": 0.65, + "moonrise_ts": 1770432076, + "moonset_ts": 1770388275, + "ozone": 405, + "pop": 50, + "precip": 0.25, + "pres": 1005, + "rh": 66, + "slp": 1008, + "snow": 3.75, + "snow_depth": 216.91895, + "sunrise_ts": 1770379197, + "sunset_ts": 1770416511, + "temp": -2, + "ts": 1770372060, + "uv": 1, + "valid_date": "2026-02-06", + "vis": 21.7, + "weather": { "code": 600, "description": "Light snow", "icon": "s01d" }, + "wind_cdir": "SSW", + "wind_cdir_full": "south-southwest", + "wind_dir": 192, + "wind_gust_spd": 3.4, + "wind_spd": 2.4 + }, + { + "app_max_temp": -4.9, + "app_min_temp": -22.5, + "clouds": 76, + "clouds_hi": 0, + "clouds_low": 87, + "clouds_mid": 57, + "datetime": "2026-02-07", + "dewpt": -13.7, + "high_temp": -5.9, + "low_temp": -13.9, + "max_dhi": null, + "max_temp": -0.2, + "min_temp": -12.2, + "moon_phase": 0.59, + "moon_phase_lunation": 0.68, + "moonrise_ts": 1770522306, + "moonset_ts": 1770476147, + "ozone": 452, + "pop": 0, + "precip": 0, + "pres": 1006, + "rh": 61, + "slp": 1009, + "snow": 0, + "snow_depth": 201.76189, + "sunrise_ts": 1770465530, + "sunset_ts": 1770502984, + "temp": -7.5, + "ts": 1770440460, + "uv": 1, + "valid_date": "2026-02-07", + "vis": 17.8, + "weather": { "code": 804, "description": "Overcast clouds", "icon": "c04d" }, + "wind_cdir": "NW", + "wind_cdir_full": "northwest", + "wind_dir": 307, + "wind_gust_spd": 13.2, + "wind_spd": 9.6 + }, + { + "app_max_temp": -16.5, + "app_min_temp": -23.8, + "clouds": 9, + "clouds_hi": 0, + "clouds_low": 17, + "clouds_mid": 0, + "datetime": "2026-02-08", + "dewpt": -18.1, + "high_temp": -7.5, + "low_temp": -12.4, + "max_dhi": null, + "max_temp": -7.5, + "min_temp": -13.9, + "moon_phase": 0.49, + "moon_phase_lunation": 0.71, + "moonrise_ts": 1770612516, + "moonset_ts": 1770564257, + "ozone": 453, + "pop": 0, + "precip": 0, + "pres": 1021, + "rh": 54, + "slp": 1024, + "snow": 0, + "snow_depth": 190.89763, + "sunrise_ts": 1770551862, + "sunset_ts": 1770589457, + "temp": -10.7, + "ts": 1770526860, + "uv": 3, + "valid_date": "2026-02-08", + "vis": 24, + "weather": { "code": 801, "description": "Few clouds", "icon": "c02d" }, + "wind_cdir": "NW", + "wind_cdir_full": "northwest", + "wind_dir": 321, + "wind_gust_spd": 11.3, + "wind_spd": 8.2 + }, + { + "app_max_temp": -7.5, + "app_min_temp": -18.4, + "clouds": 38, + "clouds_hi": 23, + "clouds_low": 36, + "clouds_mid": 53, + "datetime": "2026-02-09", + "dewpt": -13.7, + "high_temp": -1.6, + "low_temp": -7.4, + "max_dhi": null, + "max_temp": -1.6, + "min_temp": -12.4, + "moon_phase": 0.4, + "moon_phase_lunation": 0.75, + "moonrise_ts": 1770616323, + "moonset_ts": 1770652706, + "ozone": 379, + "pop": 0, + "precip": 0, + "pres": 1021, + "rh": 59, + "slp": 1024, + "snow": 0, + "snow_depth": 174.14348, + "sunrise_ts": 1770638193, + "sunset_ts": 1770675930, + "temp": -7, + "ts": 1770613260, + "uv": 2, + "valid_date": "2026-02-09", + "vis": 23.5, + "weather": { "code": 802, "description": "Scattered clouds", "icon": "c02d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 301, + "wind_gust_spd": 5.9, + "wind_spd": 4.3 + }, + { + "app_max_temp": -3.1, + "app_min_temp": -11.5, + "clouds": 36, + "clouds_hi": 45, + "clouds_low": 39, + "clouds_mid": 25, + "datetime": "2026-02-10", + "dewpt": -8.5, + "high_temp": 1.5, + "low_temp": -3, + "max_dhi": null, + "max_temp": 1.5, + "min_temp": -7.4, + "moon_phase": 0.3, + "moon_phase_lunation": 0.78, + "moonrise_ts": 1770706492, + "moonset_ts": 1770741592, + "ozone": 348, + "pop": 0, + "precip": 0, + "pres": 1018, + "rh": 65, + "slp": 1021, + "snow": 0, + "snow_depth": 150.55084, + "sunrise_ts": 1770724522, + "sunset_ts": 1770762403, + "temp": -2.9, + "ts": 1770699660, + "uv": 3, + "valid_date": "2026-02-10", + "vis": 23, + "weather": { "code": 802, "description": "Scattered clouds", "icon": "c02d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 290, + "wind_gust_spd": 3.6, + "wind_spd": 3.5 + }, + { + "app_max_temp": -1.4, + "app_min_temp": -5.8, + "clouds": 73, + "clouds_hi": 97, + "clouds_low": 55, + "clouds_mid": 81, + "datetime": "2026-02-11", + "dewpt": -4.2, + "high_temp": 3.8, + "low_temp": -3.4, + "max_dhi": null, + "max_temp": 3.8, + "min_temp": -3, + "moon_phase": 0.22, + "moon_phase_lunation": 0.81, + "moonrise_ts": 1770796533, + "moonset_ts": 1770830974, + "ozone": 327, + "pop": 80, + "precip": 6.33, + "pres": 1012, + "rh": 72, + "slp": 1014, + "snow": 8.82, + "snow_depth": 103.88888, + "sunrise_ts": 1770810850, + "sunset_ts": 1770848875, + "temp": 0.3, + "ts": 1770786060, + "uv": 2, + "valid_date": "2026-02-11", + "vis": 16.7, + "weather": { "code": 500, "description": "Light rain", "icon": "r01d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 302, + "wind_gust_spd": 4.2, + "wind_spd": 4.2 + }, + { + "app_max_temp": -5.3, + "app_min_temp": -10.9, + "clouds": 40, + "clouds_hi": 0, + "clouds_low": 40, + "clouds_mid": 0, + "datetime": "2026-02-12", + "dewpt": -5.2, + "high_temp": 0, + "low_temp": -6.3, + "max_dhi": null, + "max_temp": 0, + "min_temp": -4.6, + "moon_phase": 0.14, + "moon_phase_lunation": 0.85, + "moonrise_ts": 1770886312, + "moonset_ts": 1770920833, + "ozone": 378, + "pop": 20, + "precip": 0.125, + "pres": 1010, + "rh": 79, + "slp": 1013, + "snow": 0.625, + "snow_depth": 49.526215, + "sunrise_ts": 1770897177, + "sunset_ts": 1770935347, + "temp": -2, + "ts": 1770872460, + "uv": 4, + "valid_date": "2026-02-12", + "vis": 24, + "weather": { "code": 610, "description": "Mix snow/rain", "icon": "s04d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 299, + "wind_gust_spd": 12, + "wind_spd": 5.3 + } + ], + "lat": 40.7128, + "lon": -74.006, + "state_code": "NY", + "timezone": "America/New_York" +} diff --git a/tests/mocks/weather_weatherbit_hourly.json b/tests/mocks/weather_weatherbit_hourly.json new file mode 100644 index 0000000000..d277750081 --- /dev/null +++ b/tests/mocks/weather_weatherbit_hourly.json @@ -0,0 +1 @@ +{ "error": "Your API key does not allow access to this endpoint." } diff --git a/tests/mocks/weather_weatherflow.json b/tests/mocks/weather_weatherflow.json new file mode 100644 index 0000000000..38e4fbdbed --- /dev/null +++ b/tests/mocks/weather_weatherflow.json @@ -0,0 +1,4875 @@ +{ + "current_conditions": { + "air_density": 1.22, + "air_temperature": 16.0, + "brightness": 22546, + "conditions": "Clear", + "delta_t": 8.0, + "dew_point": -2.0, + "feels_like": 16.0, + "icon": "clear-day", + "is_precip_local_day_rain_check": true, + "is_precip_local_yesterday_rain_check": true, + "lightning_strike_count_last_1hr": 0, + "lightning_strike_count_last_3hr": 0, + "lightning_strike_last_distance": 25, + "lightning_strike_last_distance_msg": "23 - 27 km", + "lightning_strike_last_epoch": 1765159221, + "precip_accum_local_day": 0, + "precip_accum_local_yesterday": 1.75, + "precip_minutes_local_day": 0, + "precip_minutes_local_yesterday": 141, + "precip_probability": 0, + "pressure_trend": "falling", + "relative_humidity": 28, + "sea_level_pressure": 1013.6, + "solar_radiation": 188, + "station_pressure": 1013.0, + "time": 1770414299, + "uv": 1, + "wet_bulb_globe_temperature": 10.0, + "wet_bulb_temperature": 8.0, + "wind_avg": 15.0, + "wind_direction": 269, + "wind_direction_cardinal": "W", + "wind_gust": 20.0 + }, + "forecast": { + "daily": [ + { + "air_temp_high": 15.0, + "air_temp_low": 4.0, + "conditions": "Clear", + "day_num": 6, + "day_start_local": 1770354000, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770379765, + "sunset": 1770419180 + }, + { + "air_temp_high": 14.0, + "air_temp_low": 10.0, + "conditions": "Clear", + "day_num": 7, + "day_start_local": 1770440400, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770466123, + "sunset": 1770505628 + }, + { + "air_temp_high": 16.0, + "air_temp_low": 10.0, + "conditions": "Clear", + "day_num": 8, + "day_start_local": 1770526800, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770552481, + "sunset": 1770592076 + }, + { + "air_temp_high": 18.0, + "air_temp_low": 9.0, + "conditions": "Clear", + "day_num": 9, + "day_start_local": 1770613200, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770638837, + "sunset": 1770678523 + }, + { + "air_temp_high": 20.0, + "air_temp_low": 10.0, + "conditions": "Partly Cloudy", + "day_num": 10, + "day_start_local": 1770699600, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770725192, + "sunset": 1770764970 + }, + { + "air_temp_high": 21.0, + "air_temp_low": 13.0, + "conditions": "Partly Cloudy", + "day_num": 11, + "day_start_local": 1770786000, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770811546, + "sunset": 1770851417 + }, + { + "air_temp_high": 20.0, + "air_temp_low": 14.0, + "conditions": "Partly Cloudy", + "day_num": 12, + "day_start_local": 1770872400, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770897898, + "sunset": 1770937863 + }, + { + "air_temp_high": 20.0, + "air_temp_low": 14.0, + "conditions": "Partly Cloudy", + "day_num": 13, + "day_start_local": 1770958800, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "sunrise": 1770984250, + "sunset": 1771024309 + }, + { + "air_temp_high": 19.0, + "air_temp_low": 14.0, + "conditions": "Rain Possible", + "day_num": 14, + "day_start_local": 1771045200, + "icon": "possibly-rainy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 20, + "precip_type": "rain", + "sunrise": 1771070600, + "sunset": 1771110755 + }, + { + "air_temp_high": 19.0, + "air_temp_low": 14.0, + "conditions": "Partly Cloudy", + "day_num": 15, + "day_start_local": 1771131600, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "sunrise": 1771156949, + "sunset": 1771197200 + } + ], + "hourly": [ + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 6, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 42, + "sea_level_pressure": 1013.6, + "station_pressure": 1013.2, + "time": 1770415200, + "uv": 2.0, + "wind_avg": 27.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 40.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 6, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 45, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1770418800, + "uv": 0.0, + "wind_avg": 32.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 44.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 52, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770422400, + "uv": 0.0, + "wind_avg": 33.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 48.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1015.3, + "station_pressure": 1014.9, + "time": 1770426000, + "uv": 0.0, + "wind_avg": 35.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 48.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1015.2, + "station_pressure": 1014.8, + "time": 1770429600, + "uv": 0.0, + "wind_avg": 33.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 50.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1015.2, + "station_pressure": 1014.8, + "time": 1770433200, + "uv": 0.0, + "wind_avg": 32.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 48.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770436800, + "uv": 0.0, + "wind_avg": 30.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 47.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770440400, + "uv": 0.0, + "wind_avg": 29.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 45.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1015.1, + "station_pressure": 1014.7, + "time": 1770444000, + "uv": 0.0, + "wind_avg": 27.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 44.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1014.9, + "station_pressure": 1014.5, + "time": 1770447600, + "uv": 0.0, + "wind_avg": 27.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 44.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770451200, + "uv": 0.0, + "wind_avg": 26.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 44.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1015.1, + "station_pressure": 1014.7, + "time": 1770454800, + "uv": 0.0, + "wind_avg": 26.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 42.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1015.6, + "station_pressure": 1015.2, + "time": 1770458400, + "uv": 0.0, + "wind_avg": 24.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 41.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1770462000, + "uv": 0.0, + "wind_avg": 24.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 10.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1770465600, + "uv": 0.0, + "wind_avg": 24.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 39.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1770469200, + "uv": 1.0, + "wind_avg": 26.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 39.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1019.8, + "station_pressure": 1019.4, + "time": 1770472800, + "uv": 2.0, + "wind_avg": 27.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1020.4, + "station_pressure": 1020.0, + "time": 1770476400, + "uv": 4.0, + "wind_avg": 29.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 64, + "sea_level_pressure": 1020.8, + "station_pressure": 1020.4, + "time": 1770480000, + "uv": 5.0, + "wind_avg": 29.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1020.7, + "station_pressure": 1020.3, + "time": 1770483600, + "uv": 6.0, + "wind_avg": 29.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 38.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1020.0, + "station_pressure": 1019.6, + "time": 1770487200, + "uv": 7.0, + "wind_avg": 27.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1019.8, + "station_pressure": 1019.4, + "time": 1770490800, + "uv": 7.0, + "wind_avg": 27.0, + "wind_direction": 360, + "wind_direction_cardinal": "N", + "wind_gust": 38.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1019.9, + "station_pressure": 1019.5, + "time": 1770494400, + "uv": 6.0, + "wind_avg": 27.0, + "wind_direction": 0, + "wind_direction_cardinal": "N", + "wind_gust": 38.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1020.4, + "station_pressure": 1020.0, + "time": 1770498000, + "uv": 4.0, + "wind_avg": 27.0, + "wind_direction": 10, + "wind_direction_cardinal": "N", + "wind_gust": 36.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1021.1, + "station_pressure": 1020.7, + "time": 1770501600, + "uv": 2.0, + "wind_avg": 27.0, + "wind_direction": 10, + "wind_direction_cardinal": "N", + "wind_gust": 36.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1021.6, + "station_pressure": 1021.2, + "time": 1770505200, + "uv": 1.0, + "wind_avg": 26.0, + "wind_direction": 10, + "wind_direction_cardinal": "N", + "wind_gust": 36.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1022.3, + "station_pressure": 1021.9, + "time": 1770508800, + "uv": 0.0, + "wind_avg": 23.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 34.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770512400, + "uv": 0.0, + "wind_avg": 23.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 32.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770516000, + "uv": 0.0, + "wind_avg": 22.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 32.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1023.7, + "station_pressure": 1023.3, + "time": 1770519600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 31.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.0, + "station_pressure": 1023.6, + "time": 1770523200, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 28.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770526800, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 28.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770530400, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 26.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1024.2, + "station_pressure": 1023.8, + "time": 1770534000, + "uv": 0.0, + "wind_avg": 15.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 26.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.2, + "station_pressure": 1023.8, + "time": 1770537600, + "uv": 0.0, + "wind_avg": 15.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 25.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 10.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1024.3, + "station_pressure": 1023.9, + "time": 1770541200, + "uv": 0.0, + "wind_avg": 14.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 25.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 10.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 70, + "sea_level_pressure": 1024.7, + "station_pressure": 1024.3, + "time": 1770544800, + "uv": 0.0, + "wind_avg": 14.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 24.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 8.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1025.1, + "station_pressure": 1024.7, + "time": 1770548400, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 23.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 8.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1025.4, + "station_pressure": 1025.0, + "time": 1770552000, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1025.9, + "station_pressure": 1025.5, + "time": 1770555600, + "uv": 4.0, + "wind_avg": 13.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1026.4, + "station_pressure": 1026.0, + "time": 1770559200, + "uv": 4.0, + "wind_avg": 14.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1026.9, + "station_pressure": 1026.5, + "time": 1770562800, + "uv": 4.0, + "wind_avg": 14.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 62, + "sea_level_pressure": 1026.4, + "station_pressure": 1026.0, + "time": 1770566400, + "uv": 6.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1026.0, + "station_pressure": 1025.6, + "time": 1770570000, + "uv": 6.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 60, + "sea_level_pressure": 1025.5, + "station_pressure": 1025.1, + "time": 1770573600, + "uv": 6.0, + "wind_avg": 16.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770577200, + "uv": 5.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 62, + "sea_level_pressure": 1025.1, + "station_pressure": 1024.7, + "time": 1770580800, + "uv": 5.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 20.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 8, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1025.0, + "station_pressure": 1024.6, + "time": 1770584400, + "uv": 5.0, + "wind_avg": 14.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 19.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770588000, + "uv": 1.0, + "wind_avg": 12.0, + "wind_direction": 40, + "wind_direction_cardinal": "NE", + "wind_gust": 18.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1025.7, + "station_pressure": 1025.3, + "time": 1770591600, + "uv": 1.0, + "wind_avg": 10.0, + "wind_direction": 40, + "wind_direction_cardinal": "NE", + "wind_gust": 16.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1026.1, + "station_pressure": 1025.7, + "time": 1770595200, + "uv": 1.0, + "wind_avg": 7.0, + "wind_direction": 40, + "wind_direction_cardinal": "NE", + "wind_gust": 14.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1026.1, + "station_pressure": 1025.7, + "time": 1770598800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 60, + "wind_direction_cardinal": "ENE", + "wind_gust": 13.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1026.1, + "station_pressure": 1025.7, + "time": 1770602400, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 60, + "wind_direction_cardinal": "ENE", + "wind_gust": 12.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1026.2, + "station_pressure": 1025.8, + "time": 1770606000, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 60, + "wind_direction_cardinal": "ENE", + "wind_gust": 11.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1026.0, + "station_pressure": 1025.6, + "time": 1770609600, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 11.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1025.9, + "station_pressure": 1025.5, + "time": 1770613200, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 11.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1025.8, + "station_pressure": 1025.4, + "time": 1770616800, + "uv": 0.0, + "wind_avg": 4.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 11.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770620400, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 12.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 10.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 84, + "sea_level_pressure": 1024.8, + "station_pressure": 1024.4, + "time": 1770624000, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 12.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 9.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1024.3, + "station_pressure": 1023.9, + "time": 1770627600, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 13.0 + }, + { + "air_temperature": 9.0, + "conditions": "Partly Cloudy", + "feels_like": 9.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1024.7, + "station_pressure": 1024.3, + "time": 1770631200, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 14.0 + }, + { + "air_temperature": 9.0, + "conditions": "Partly Cloudy", + "feels_like": 8.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1025.0, + "station_pressure": 1024.6, + "time": 1770634800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 14.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 9.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770638400, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 15.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-day", + "local_day": 9, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1025.8, + "station_pressure": 1025.4, + "time": 1770642000, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 15.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-day", + "local_day": 9, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1026.3, + "station_pressure": 1025.9, + "time": 1770645600, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 16.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1026.8, + "station_pressure": 1026.4, + "time": 1770649200, + "uv": 2.0, + "wind_avg": 9.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 16.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1026.2, + "station_pressure": 1025.8, + "time": 1770652800, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 16.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1025.5, + "station_pressure": 1025.1, + "time": 1770656400, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 17.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 57, + "sea_level_pressure": 1024.8, + "station_pressure": 1024.4, + "time": 1770660000, + "uv": 4.0, + "wind_avg": 12.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 17.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 57, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770663600, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 18.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 56, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770667200, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 18.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 56, + "sea_level_pressure": 1022.7, + "station_pressure": 1022.3, + "time": 1770670800, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 19.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770674400, + "uv": 3.0, + "wind_avg": 11.0, + "wind_direction": 160, + "wind_direction_cardinal": "SSE", + "wind_gust": 18.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1023.1, + "station_pressure": 1022.7, + "time": 1770678000, + "uv": 3.0, + "wind_avg": 10.0, + "wind_direction": 160, + "wind_direction_cardinal": "SSE", + "wind_gust": 16.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1023.3, + "station_pressure": 1022.9, + "time": 1770681600, + "uv": 3.0, + "wind_avg": 9.0, + "wind_direction": 160, + "wind_direction_cardinal": "SSE", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770685200, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1023.5, + "station_pressure": 1023.1, + "time": 1770688800, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 17.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1023.6, + "station_pressure": 1023.2, + "time": 1770692400, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 17.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770696000, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 18.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 10, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1023.2, + "station_pressure": 1022.8, + "time": 1770699600, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 18.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1023.0, + "station_pressure": 1022.6, + "time": 1770703200, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 19.0 + }, + { + "air_temperature": 12.0, + "conditions": "Partly Cloudy", + "feels_like": 12.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770706800, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 18.0 + }, + { + "air_temperature": 12.0, + "conditions": "Partly Cloudy", + "feels_like": 12.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770710400, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1022.8, + "station_pressure": 1022.4, + "time": 1770714000, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 16.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1023.2, + "station_pressure": 1022.8, + "time": 1770717600, + "uv": 0.0, + "wind_avg": 8.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 10.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1023.5, + "station_pressure": 1023.1, + "time": 1770721200, + "uv": 0.0, + "wind_avg": 8.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1023.9, + "station_pressure": 1023.5, + "time": 1770724800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 12.0, + "conditions": "Partly Cloudy", + "feels_like": 12.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1024.2, + "station_pressure": 1023.8, + "time": 1770728400, + "uv": 2.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1024.5, + "station_pressure": 1024.1, + "time": 1770732000, + "uv": 2.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1024.8, + "station_pressure": 1024.4, + "time": 1770735600, + "uv": 2.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770739200, + "uv": 4.0, + "wind_avg": 8.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 14.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1023.3, + "station_pressure": 1022.9, + "time": 1770742800, + "uv": 4.0, + "wind_avg": 8.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 14.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 60, + "sea_level_pressure": 1022.5, + "station_pressure": 1022.1, + "time": 1770746400, + "uv": 4.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 14.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1021.9, + "station_pressure": 1021.5, + "time": 1770750000, + "uv": 5.0, + "wind_avg": 9.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 14.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1021.3, + "station_pressure": 1020.9, + "time": 1770753600, + "uv": 5.0, + "wind_avg": 10.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 15.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1020.6, + "station_pressure": 1020.2, + "time": 1770757200, + "uv": 5.0, + "wind_avg": 10.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 15.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1021.1, + "station_pressure": 1020.7, + "time": 1770760800, + "uv": 3.0, + "wind_avg": 9.0, + "wind_direction": 140, + "wind_direction_cardinal": "SE", + "wind_gust": 15.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1021.5, + "station_pressure": 1021.1, + "time": 1770764400, + "uv": 3.0, + "wind_avg": 8.0, + "wind_direction": 140, + "wind_direction_cardinal": "SE", + "wind_gust": 14.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1022.0, + "station_pressure": 1021.6, + "time": 1770768000, + "uv": 3.0, + "wind_avg": 7.0, + "wind_direction": 140, + "wind_direction_cardinal": "SE", + "wind_gust": 14.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1022.2, + "station_pressure": 1021.8, + "time": 1770771600, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 200, + "wind_direction_cardinal": "SSW", + "wind_gust": 14.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1022.4, + "station_pressure": 1022.0, + "time": 1770775200, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 200, + "wind_direction_cardinal": "SSW", + "wind_gust": 14.0 + }, + { + "air_temperature": 16.0, + "conditions": "Cloudy", + "feels_like": 16.0, + "icon": "cloudy", + "local_day": 10, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1022.6, + "station_pressure": 1022.2, + "time": 1770778800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 200, + "wind_direction_cardinal": "SSW", + "wind_gust": 15.0 + }, + { + "air_temperature": 16.0, + "conditions": "Cloudy", + "feels_like": 16.0, + "icon": "cloudy", + "local_day": 10, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1022.4, + "station_pressure": 1022.0, + "time": 1770782400, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Cloudy", + "feels_like": 15.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1022.3, + "station_pressure": 1021.9, + "time": 1770786000, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Cloudy", + "feels_like": 15.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1022.1, + "station_pressure": 1021.7, + "time": 1770789600, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 14.0, + "conditions": "Cloudy", + "feels_like": 14.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1021.8, + "station_pressure": 1021.4, + "time": 1770793200, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 16.0 + }, + { + "air_temperature": 14.0, + "conditions": "Cloudy", + "feels_like": 14.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1021.6, + "station_pressure": 1021.2, + "time": 1770796800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 14.0, + "conditions": "Cloudy", + "feels_like": 14.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1021.4, + "station_pressure": 1021.0, + "time": 1770800400, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 11, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1021.4, + "station_pressure": 1021.0, + "time": 1770804000, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 11, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1021.5, + "station_pressure": 1021.1, + "time": 1770807600, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 11, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1021.5, + "station_pressure": 1021.1, + "time": 1770811200, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 17.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1021.7, + "station_pressure": 1021.3, + "time": 1770814800, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1022.0, + "station_pressure": 1021.6, + "time": 1770818400, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1022.2, + "station_pressure": 1021.8, + "time": 1770822000, + "uv": 2.0, + "wind_avg": 9.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1021.3, + "station_pressure": 1020.9, + "time": 1770825600, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 18.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1020.4, + "station_pressure": 1020.0, + "time": 1770829200, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 18.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1019.5, + "station_pressure": 1019.1, + "time": 1770832800, + "uv": 4.0, + "wind_avg": 12.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 19.0 + }, + { + "air_temperature": 21.0, + "conditions": "Partly Cloudy", + "feels_like": 21.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1019.1, + "station_pressure": 1018.7, + "time": 1770836400, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 19.0 + }, + { + "air_temperature": 21.0, + "conditions": "Clear", + "feels_like": 21.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1018.6, + "station_pressure": 1018.2, + "time": 1770840000, + "uv": 5.0, + "wind_avg": 13.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 19.0 + }, + { + "air_temperature": 21.0, + "conditions": "Clear", + "feels_like": 21.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1770843600, + "uv": 5.0, + "wind_avg": 13.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 19.0 + }, + { + "air_temperature": 20.0, + "conditions": "Clear", + "feels_like": 20.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1018.4, + "station_pressure": 1018.0, + "time": 1770847200, + "uv": 3.0, + "wind_avg": 13.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 20.0, + "conditions": "Clear", + "feels_like": 20.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1018.7, + "station_pressure": 1018.3, + "time": 1770850800, + "uv": 3.0, + "wind_avg": 12.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 19.0, + "conditions": "Clear", + "feels_like": 19.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1019.0, + "station_pressure": 1018.6, + "time": 1770854400, + "uv": 3.0, + "wind_avg": 12.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1019.1, + "station_pressure": 1018.7, + "time": 1770858000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 20.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1019.3, + "station_pressure": 1018.9, + "time": 1770861600, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1019.4, + "station_pressure": 1019.0, + "time": 1770865200, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1019.0, + "station_pressure": 1018.6, + "time": 1770868800, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1770872400, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1770876000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1017.9, + "station_pressure": 1017.5, + "time": 1770879600, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1017.7, + "station_pressure": 1017.3, + "time": 1770883200, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770886800, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 97, + "sea_level_pressure": 1017.9, + "station_pressure": 1017.5, + "time": 1770890400, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 99, + "sea_level_pressure": 1018.2, + "station_pressure": 1017.8, + "time": 1770894000, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 98, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1770897600, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 100, + "sea_level_pressure": 1018.6, + "station_pressure": 1018.2, + "time": 1770901200, + "uv": 2.0, + "wind_avg": 13.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 93, + "sea_level_pressure": 1018.7, + "station_pressure": 1018.3, + "time": 1770904800, + "uv": 2.0, + "wind_avg": 14.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 22.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1018.8, + "station_pressure": 1018.4, + "time": 1770908400, + "uv": 2.0, + "wind_avg": 14.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 22.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1770912000, + "uv": 4.0, + "wind_avg": 15.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770915600, + "uv": 4.0, + "wind_avg": 15.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1016.8, + "station_pressure": 1016.4, + "time": 1770919200, + "uv": 4.0, + "wind_avg": 16.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1770922800, + "uv": 1.0, + "wind_avg": 16.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.1, + "station_pressure": 1015.7, + "time": 1770926400, + "uv": 1.0, + "wind_avg": 15.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1015.7, + "station_pressure": 1015.3, + "time": 1770930000, + "uv": 1.0, + "wind_avg": 15.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 22.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1016.2, + "station_pressure": 1015.8, + "time": 1770933600, + "uv": 1.0, + "wind_avg": 14.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 21.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1016.7, + "station_pressure": 1016.3, + "time": 1770937200, + "uv": 1.0, + "wind_avg": 13.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 21.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1770940800, + "uv": 1.0, + "wind_avg": 12.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1017.4, + "station_pressure": 1017.0, + "time": 1770944400, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 84, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1770948000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1017.8, + "station_pressure": 1017.4, + "time": 1770951600, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1770955200, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770958800, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1017.3, + "station_pressure": 1016.9, + "time": 1770962400, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1770966000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1017.1, + "station_pressure": 1016.7, + "time": 1770969600, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1017.0, + "station_pressure": 1016.6, + "time": 1770973200, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770976800, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 94, + "sea_level_pressure": 1017.9, + "station_pressure": 1017.5, + "time": 1770980400, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 24.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 93, + "sea_level_pressure": 1018.3, + "station_pressure": 1017.9, + "time": 1770984000, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 24.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 94, + "sea_level_pressure": 1018.6, + "station_pressure": 1018.2, + "time": 1770987600, + "uv": 2.0, + "wind_avg": 14.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 24.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1019.0, + "station_pressure": 1018.6, + "time": 1770991200, + "uv": 2.0, + "wind_avg": 15.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 24.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1019.4, + "station_pressure": 1019.0, + "time": 1770994800, + "uv": 2.0, + "wind_avg": 16.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 24.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1018.9, + "station_pressure": 1018.5, + "time": 1770998400, + "uv": 4.0, + "wind_avg": 17.0, + "wind_direction": 50, + "wind_direction_cardinal": "NE", + "wind_gust": 25.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1771002000, + "uv": 4.0, + "wind_avg": 17.0, + "wind_direction": 50, + "wind_direction_cardinal": "NE", + "wind_gust": 26.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1771005600, + "uv": 4.0, + "wind_avg": 18.0, + "wind_direction": 50, + "wind_direction_cardinal": "NE", + "wind_gust": 26.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1771009200, + "uv": 5.0, + "wind_avg": 18.0, + "wind_direction": 90, + "wind_direction_cardinal": "E", + "wind_gust": 26.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1017.1, + "station_pressure": 1016.7, + "time": 1771012800, + "uv": 5.0, + "wind_avg": 18.0, + "wind_direction": 90, + "wind_direction_cardinal": "E", + "wind_gust": 27.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.6, + "station_pressure": 1016.2, + "time": 1771016400, + "uv": 5.0, + "wind_avg": 19.0, + "wind_direction": 90, + "wind_direction_cardinal": "E", + "wind_gust": 27.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1016.9, + "station_pressure": 1016.5, + "time": 1771020000, + "uv": 3.0, + "wind_avg": 18.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 26.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1771023600, + "uv": 3.0, + "wind_avg": 17.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 26.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 13, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1771027200, + "uv": 3.0, + "wind_avg": 16.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 25.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1771030800, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 100, + "wind_direction_cardinal": "E", + "wind_gust": 26.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1771034400, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 100, + "wind_direction_cardinal": "E", + "wind_gust": 26.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1017.7, + "station_pressure": 1017.3, + "time": 1771038000, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 100, + "wind_direction_cardinal": "E", + "wind_gust": 27.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 84, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1771041600, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 27.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1016.8, + "station_pressure": 1016.4, + "time": 1771045200, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 27.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1771048800, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 28.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 2, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1015.9, + "station_pressure": 1015.5, + "time": 1771052400, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 28.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 3, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1015.5, + "station_pressure": 1015.1, + "time": 1771056000, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 28.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 4, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1771059600, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 29.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 5, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1015.4, + "station_pressure": 1015.0, + "time": 1771063200, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 29.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 6, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 95, + "sea_level_pressure": 1015.8, + "station_pressure": 1015.4, + "time": 1771066800, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 30.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 94, + "sea_level_pressure": 1016.2, + "station_pressure": 1015.8, + "time": 1771070400, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 30.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1771074000, + "uv": 2.0, + "wind_avg": 19.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 30.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1016.6, + "station_pressure": 1016.2, + "time": 1771077600, + "uv": 2.0, + "wind_avg": 20.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 31.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1016.8, + "station_pressure": 1016.4, + "time": 1771081200, + "uv": 2.0, + "wind_avg": 21.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 31.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 11, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.1, + "station_pressure": 1015.7, + "time": 1771084800, + "uv": 4.0, + "wind_avg": 21.0, + "wind_direction": 210, + "wind_direction_cardinal": "SSW", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 12, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1015.4, + "station_pressure": 1015.0, + "time": 1771088400, + "uv": 4.0, + "wind_avg": 22.0, + "wind_direction": 210, + "wind_direction_cardinal": "SSW", + "wind_gust": 32.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 13, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1014.7, + "station_pressure": 1014.3, + "time": 1771092000, + "uv": 4.0, + "wind_avg": 22.0, + "wind_direction": 210, + "wind_direction_cardinal": "SSW", + "wind_gust": 33.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 14, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771095600, + "uv": 3.0, + "wind_avg": 22.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 33.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 15, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771099200, + "uv": 3.0, + "wind_avg": 22.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 16, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1013.9, + "station_pressure": 1013.5, + "time": 1771102800, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 17, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1013.7, + "station_pressure": 1013.3, + "time": 1771106400, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 18, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1013.4, + "station_pressure": 1013.0, + "time": 1771110000, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 19, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 15, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.2, + "station_pressure": 1012.8, + "time": 1771113600, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 20, + "precip": 0.21, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1013.3, + "station_pressure": 1012.9, + "time": 1771117200, + "uv": 0.0, + "wind_avg": 21.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 32.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 21, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.4, + "station_pressure": 1013.0, + "time": 1771120800, + "uv": 0.0, + "wind_avg": 20.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 32.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 22, + "precip": 0.13, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1013.6, + "station_pressure": 1013.2, + "time": 1771124400, + "uv": 0.0, + "wind_avg": 20.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 32.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 23, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1013.7, + "station_pressure": 1013.3, + "time": 1771128000, + "uv": 0.0, + "wind_avg": 20.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 0, + "precip": 0.04, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771131600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1014.0, + "station_pressure": 1013.6, + "time": 1771135200, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1014.1, + "station_pressure": 1013.7, + "time": 1771138800, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771142400, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1014.3, + "station_pressure": 1013.9, + "time": 1771146000, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771149600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1014.5, + "station_pressure": 1014.1, + "time": 1771153200, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1014.6, + "station_pressure": 1014.2, + "time": 1771156800, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 8, + "precip": 0.04, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 93, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771160400, + "uv": 4.0, + "wind_avg": 19.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 9, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771164000, + "uv": 4.0, + "wind_avg": 19.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 10, + "precip": 0.13, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1014.0, + "station_pressure": 1013.6, + "time": 1771167600, + "uv": 4.0, + "wind_avg": 20.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 11, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771171200, + "uv": 4.0, + "wind_avg": 20.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 12, + "precip": 0.21, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.5, + "station_pressure": 1013.1, + "time": 1771174800, + "uv": 4.0, + "wind_avg": 21.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 13, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 70, + "sea_level_pressure": 1013.3, + "station_pressure": 1012.9, + "time": 1771178400, + "uv": 4.0, + "wind_avg": 21.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 14, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1013.4, + "station_pressure": 1013.0, + "time": 1771182000, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 15, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1013.5, + "station_pressure": 1013.1, + "time": 1771185600, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 16, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1013.6, + "station_pressure": 1013.2, + "time": 1771189200, + "uv": 3.0, + "wind_avg": 20.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 17, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1013.7, + "station_pressure": 1013.3, + "time": 1771192800, + "uv": 3.0, + "wind_avg": 20.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 18, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771196400, + "uv": 3.0, + "wind_avg": 20.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 19, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771200000, + "uv": 3.0, + "wind_avg": 19.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 20, + "precip": 0.21, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1014.0, + "station_pressure": 1013.6, + "time": 1771203600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 21, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771207200, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 22, + "precip": 0.13, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771210800, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 23, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1014.6, + "station_pressure": 1014.2, + "time": 1771214400, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + } + ] + }, + "latitude": 29.05592, + "location_name": "OG Pergola", + "longitude": -80.90748, + "source_id_conditions": 5, + "station": { "agl": 1.8288, "elevation": 3.0345869064331055, "is_station_online": true, "state": 1, "station_id": 151283 }, + "status": { "status_code": 0, "status_message": "SUCCESS" }, + "timezone": "America/New_York", + "timezone_offset_minutes": -300, + "units": { "units_air_density": "kg/m3", "units_brightness": "lux", "units_distance": "km", "units_other": "metric", "units_precip": "mm", "units_pressure": "mb", "units_solar_radiation": "w/m2", "units_temp": "c", "units_wind": "kph" } +} diff --git a/tests/mocks/weather_weathergov_current.json b/tests/mocks/weather_weathergov_current.json new file mode 100644 index 0000000000..770bba1ff8 --- /dev/null +++ b/tests/mocks/weather_weathergov_current.json @@ -0,0 +1,151 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + } + } + ], + "id": "https://api.weather.gov/stations/KDCA/observations/2026-02-06T21:30:00+00:00", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.03, 38.85] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDCA/observations/2026-02-06T21:30:00+00:00", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 20 + }, + "station": "https://api.weather.gov/stations/KDCA", + "stationId": "KDCA", + "stationName": "Washington/Reagan National Airport, DC", + "timestamp": "2026-02-06T21:30:00+00:00", + "rawMessage": "", + "textDescription": "Light Snow", + "icon": "https://api.weather.gov/icons/land/day/snow?size=medium", + "presentWeather": [ + { + "intensity": "light", + "modifier": null, + "weather": "snow", + "rawString": "-SN" + } + ], + "temperature": { + "unitCode": "wmoUnit:degC", + "value": -1, + "qualityControl": "V" + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7, + "qualityControl": "V" + }, + "windDirection": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 0, + "qualityControl": "V" + }, + "windSpeed": { + "unitCode": "wmoUnit:km_h-1", + "value": 0, + "qualityControl": "V" + }, + "windGust": { + "unitCode": "wmoUnit:km_h-1", + "value": null, + "qualityControl": "Z" + }, + "barometricPressure": { + "unitCode": "wmoUnit:Pa", + "value": 100372.55, + "qualityControl": "V" + }, + "seaLevelPressure": { + "unitCode": "wmoUnit:Pa", + "value": null, + "qualityControl": "Z" + }, + "visibility": { + "unitCode": "wmoUnit:m", + "value": 9656.06, + "qualityControl": "C" + }, + "maxTemperatureLast24Hours": { + "unitCode": "wmoUnit:degC", + "value": null + }, + "minTemperatureLast24Hours": { + "unitCode": "wmoUnit:degC", + "value": null + }, + "precipitationLast3Hours": { + "unitCode": "wmoUnit:mm", + "value": null, + "qualityControl": "Z" + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 63.771213893297, + "qualityControl": "V" + }, + "windChill": { + "unitCode": "wmoUnit:degC", + "value": null, + "qualityControl": "V" + }, + "heatIndex": { + "unitCode": "wmoUnit:degC", + "value": null, + "qualityControl": "V" + }, + "cloudLayers": [ + { + "base": { + "unitCode": "wmoUnit:m", + "value": 944.88 + }, + "amount": "OVC" + } + ] + } +} diff --git a/tests/mocks/weather_weathergov_forecast.json b/tests/mocks/weather_weathergov_forecast.json new file mode 100644 index 0000000000..258d86532b --- /dev/null +++ b/tests/mocks/weather_weathergov_forecast.json @@ -0,0 +1,304 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#" + } + ], + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77.0445, 38.8569], + [-77.0408, 38.8788], + [-77.0689, 38.8818], + [-77.0727, 38.8598], + [-77.0445, 38.8569] + ] + ] + }, + "properties": { + "units": "si", + "forecastGenerator": "BaselineForecastGenerator", + "generatedAt": "2026-02-06T21:45:05+00:00", + "updateTime": "2026-02-06T20:53:00+00:00", + "validTimes": "2026-02-06T14:00:00+00:00/P7DT14H", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 7.9248 + }, + "periods": [ + { + "number": 1, + "name": "This Afternoon", + "startTime": "2026-02-06T16:00:00-05:00", + "endTime": "2026-02-06T18:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/snow,70?size=medium", + "shortForecast": "Light Snow Likely", + "detailedForecast": "Snow likely. Cloudy, with a high near 1. South wind around 4 km/h. Chance of precipitation is 70%. New snow accumulation of less than one cm possible." + }, + { + "number": 2, + "name": "Tonight", + "startTime": "2026-02-06T18:00:00-05:00", + "endTime": "2026-02-07T06:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 45 + }, + "windSpeed": "2 to 35 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/snow,50/wind_bkn?size=medium", + "shortForecast": "Chance Light Snow then Mostly Cloudy", + "detailedForecast": "A chance of snow before 10pm. Mostly cloudy. Low around -11, with temperatures rising to around -7 overnight. West wind 2 to 35 km/h, with gusts as high as 63 km/h. Chance of precipitation is 50%. New snow accumulation of less than two cm possible." + }, + { + "number": 3, + "name": "Saturday", + "startTime": "2026-02-07T06:00:00-05:00", + "endTime": "2026-02-07T18:00:00-05:00", + "isDaytime": true, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "37 to 48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=medium", + "shortForecast": "Mostly Sunny", + "detailedForecast": "Mostly sunny, with a high near -7. Wind chill values as low as -21. Northwest wind 37 to 48 km/h, with gusts as high as 94 km/h." + }, + { + "number": 4, + "name": "Saturday Night", + "startTime": "2026-02-07T18:00:00-05:00", + "endTime": "2026-02-08T06:00:00-05:00", + "isDaytime": false, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "22 to 43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=medium", + "shortForecast": "Mostly Clear", + "detailedForecast": "Mostly clear, with a low around -12. Wind chill values as low as -21. Northwest wind 22 to 43 km/h, with gusts as high as 76 km/h." + }, + { + "number": 5, + "name": "Sunday", + "startTime": "2026-02-08T06:00:00-05:00", + "endTime": "2026-02-08T18:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "windSpeed": "13 to 22 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=medium", + "shortForecast": "Mostly Sunny", + "detailedForecast": "Mostly sunny, with a high near -4. Northwest wind 13 to 22 km/h, with gusts as high as 43 km/h." + }, + { + "number": 6, + "name": "Sunday Night", + "startTime": "2026-02-08T18:00:00-05:00", + "endTime": "2026-02-09T06:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "windSpeed": "4 to 9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=medium", + "shortForecast": "Partly Cloudy", + "detailedForecast": "Partly cloudy, with a low around -11." + }, + { + "number": 7, + "name": "Monday", + "startTime": "2026-02-09T06:00:00-05:00", + "endTime": "2026-02-09T18:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=medium", + "shortForecast": "Partly Sunny", + "detailedForecast": "Partly sunny, with a high near 0." + }, + { + "number": 8, + "name": "Monday Night", + "startTime": "2026-02-09T18:00:00-05:00", + "endTime": "2026-02-10T06:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=medium", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "Mostly cloudy, with a low around -6." + }, + { + "number": 9, + "name": "Tuesday", + "startTime": "2026-02-10T06:00:00-05:00", + "endTime": "2026-02-10T18:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=medium", + "shortForecast": "Mostly Sunny", + "detailedForecast": "Mostly sunny, with a high near 7." + }, + { + "number": 10, + "name": "Tuesday Night", + "startTime": "2026-02-10T18:00:00-05:00", + "endTime": "2026-02-11T06:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn/rain,20?size=medium", + "shortForecast": "Mostly Cloudy then Slight Chance Light Rain", + "detailedForecast": "A slight chance of rain after 1am. Mostly cloudy, with a low around -1." + }, + { + "number": 11, + "name": "Wednesday", + "startTime": "2026-02-11T06:00:00-05:00", + "endTime": "2026-02-11T18:00:00-05:00", + "isDaytime": true, + "temperature": 8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "4 to 11 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=medium", + "shortForecast": "Chance Light Rain", + "detailedForecast": "A chance of rain. Mostly cloudy, with a high near 8. Chance of precipitation is 50%." + }, + { + "number": 12, + "name": "Wednesday Night", + "startTime": "2026-02-11T18:00:00-05:00", + "endTime": "2026-02-12T06:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,50/rain,30?size=medium", + "shortForecast": "Chance Light Rain", + "detailedForecast": "A chance of rain. Mostly cloudy, with a low around -1. Chance of precipitation is 50%." + }, + { + "number": 13, + "name": "Thursday", + "startTime": "2026-02-12T06:00:00-05:00", + "endTime": "2026-02-12T18:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "windSpeed": "11 to 17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,30/rain,20?size=medium", + "shortForecast": "Chance Light Rain", + "detailedForecast": "A chance of rain before 1pm. Mostly cloudy, with a high near 4. Chance of precipitation is 30%." + }, + { + "number": 14, + "name": "Thursday Night", + "startTime": "2026-02-12T18:00:00-05:00", + "endTime": "2026-02-13T06:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=medium", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "A slight chance of snow after 7pm. Mostly cloudy, with a low around -3." + } + ] + } +} diff --git a/tests/mocks/weather_weathergov_hourly.json b/tests/mocks/weather_weathergov_hourly.json new file mode 100644 index 0000000000..e4fc3bb862 --- /dev/null +++ b/tests/mocks/weather_weathergov_hourly.json @@ -0,0 +1,4250 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#" + } + ], + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77.0445, 38.8569], + [-77.0408, 38.8788], + [-77.0689, 38.8818], + [-77.0727, 38.8598], + [-77.0445, 38.8569] + ] + ] + }, + "properties": { + "units": "si", + "forecastGenerator": "HourlyForecastGenerator", + "generatedAt": "2026-02-06T21:45:06+00:00", + "updateTime": "2026-02-06T20:53:00+00:00", + "validTimes": "2026-02-06T14:00:00+00:00/P7DT14H", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 7.9248 + }, + "periods": [ + { + "number": 1, + "name": "", + "startTime": "2026-02-06T16:00:00-05:00", + "endTime": "2026-02-06T17:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 72 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/snow,70?size=small", + "shortForecast": "Light Snow Likely", + "detailedForecast": "" + }, + { + "number": 2, + "name": "", + "startTime": "2026-02-06T17:00:00-05:00", + "endTime": "2026-02-06T18:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/snow,60?size=small", + "shortForecast": "Light Snow Likely", + "detailedForecast": "" + }, + { + "number": 3, + "name": "", + "startTime": "2026-02-06T18:00:00-05:00", + "endTime": "2026-02-06T19:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 45 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/snow,50?size=small", + "shortForecast": "Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 4, + "name": "", + "startTime": "2026-02-06T19:00:00-05:00", + "endTime": "2026-02-06T20:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 37 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/snow,40?size=small", + "shortForecast": "Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 5, + "name": "", + "startTime": "2026-02-06T20:00:00-05:00", + "endTime": "2026-02-06T21:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 30 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "4 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/night/snow,30?size=small", + "shortForecast": "Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 6, + "name": "", + "startTime": "2026-02-06T21:00:00-05:00", + "endTime": "2026-02-06T22:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 17 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "4 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 7, + "name": "", + "startTime": "2026-02-06T22:00:00-05:00", + "endTime": "2026-02-06T23:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 13 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "6 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 8, + "name": "", + "startTime": "2026-02-06T23:00:00-05:00", + "endTime": "2026-02-07T00:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 5 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 9, + "name": "", + "startTime": "2026-02-07T00:00:00-05:00", + "endTime": "2026-02-07T01:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 7 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "9 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 10, + "name": "", + "startTime": "2026-02-07T01:00:00-05:00", + "endTime": "2026-02-07T02:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 11, + "name": "", + "startTime": "2026-02-07T02:00:00-05:00", + "endTime": "2026-02-07T03:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 4 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 12, + "name": "", + "startTime": "2026-02-07T03:00:00-05:00", + "endTime": "2026-02-07T04:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 4 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7.777777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "24 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 13, + "name": "", + "startTime": "2026-02-07T04:00:00-05:00", + "endTime": "2026-02-07T05:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "31 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 14, + "name": "", + "startTime": "2026-02-07T05:00:00-05:00", + "endTime": "2026-02-07T06:00:00-05:00", + "isDaytime": false, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -11.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "35 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 15, + "name": "", + "startTime": "2026-02-07T06:00:00-05:00", + "endTime": "2026-02-07T07:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 64 + }, + "windSpeed": "37 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 16, + "name": "", + "startTime": "2026-02-07T07:00:00-05:00", + "endTime": "2026-02-07T08:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "39 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 17, + "name": "", + "startTime": "2026-02-07T08:00:00-05:00", + "endTime": "2026-02-07T09:00:00-05:00", + "isDaytime": true, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -16.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 18, + "name": "", + "startTime": "2026-02-07T09:00:00-05:00", + "endTime": "2026-02-07T10:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.22222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "44 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 19, + "name": "", + "startTime": "2026-02-07T10:00:00-05:00", + "endTime": "2026-02-07T11:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 48 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 20, + "name": "", + "startTime": "2026-02-07T11:00:00-05:00", + "endTime": "2026-02-07T12:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 48 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 21, + "name": "", + "startTime": "2026-02-07T12:00:00-05:00", + "endTime": "2026-02-07T13:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 44 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 22, + "name": "", + "startTime": "2026-02-07T13:00:00-05:00", + "endTime": "2026-02-07T14:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 46 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 23, + "name": "", + "startTime": "2026-02-07T14:00:00-05:00", + "endTime": "2026-02-07T15:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 42 + }, + "windSpeed": "46 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 24, + "name": "", + "startTime": "2026-02-07T15:00:00-05:00", + "endTime": "2026-02-07T16:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 42 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 25, + "name": "", + "startTime": "2026-02-07T16:00:00-05:00", + "endTime": "2026-02-07T17:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 44 + }, + "windSpeed": "41 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 26, + "name": "", + "startTime": "2026-02-07T17:00:00-05:00", + "endTime": "2026-02-07T18:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 42 + }, + "windSpeed": "41 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 27, + "name": "", + "startTime": "2026-02-07T18:00:00-05:00", + "endTime": "2026-02-07T19:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 46 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 28, + "name": "", + "startTime": "2026-02-07T19:00:00-05:00", + "endTime": "2026-02-07T20:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 44 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 29, + "name": "", + "startTime": "2026-02-07T20:00:00-05:00", + "endTime": "2026-02-07T21:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.22222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 51 + }, + "windSpeed": "39 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 30, + "name": "", + "startTime": "2026-02-07T21:00:00-05:00", + "endTime": "2026-02-07T22:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "37 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 31, + "name": "", + "startTime": "2026-02-07T22:00:00-05:00", + "endTime": "2026-02-07T23:00:00-05:00", + "isDaytime": false, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "33 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 32, + "name": "", + "startTime": "2026-02-07T23:00:00-05:00", + "endTime": "2026-02-08T00:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "31 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 33, + "name": "", + "startTime": "2026-02-08T00:00:00-05:00", + "endTime": "2026-02-08T01:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "28 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 34, + "name": "", + "startTime": "2026-02-08T01:00:00-05:00", + "endTime": "2026-02-08T02:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "28 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 35, + "name": "", + "startTime": "2026-02-08T02:00:00-05:00", + "endTime": "2026-02-08T03:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "26 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 36, + "name": "", + "startTime": "2026-02-08T03:00:00-05:00", + "endTime": "2026-02-08T04:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 58 + }, + "windSpeed": "24 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 37, + "name": "", + "startTime": "2026-02-08T04:00:00-05:00", + "endTime": "2026-02-08T05:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 58 + }, + "windSpeed": "24 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 38, + "name": "", + "startTime": "2026-02-08T05:00:00-05:00", + "endTime": "2026-02-08T06:00:00-05:00", + "isDaytime": false, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 60 + }, + "windSpeed": "22 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 39, + "name": "", + "startTime": "2026-02-08T06:00:00-05:00", + "endTime": "2026-02-08T07:00:00-05:00", + "isDaytime": true, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "22 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 40, + "name": "", + "startTime": "2026-02-08T07:00:00-05:00", + "endTime": "2026-02-08T08:00:00-05:00", + "isDaytime": true, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "20 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 41, + "name": "", + "startTime": "2026-02-08T08:00:00-05:00", + "endTime": "2026-02-08T09:00:00-05:00", + "isDaytime": true, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "20 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 42, + "name": "", + "startTime": "2026-02-08T09:00:00-05:00", + "endTime": "2026-02-08T10:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.22222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 43, + "name": "", + "startTime": "2026-02-08T10:00:00-05:00", + "endTime": "2026-02-08T11:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -16.666666666666668 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 51 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 44, + "name": "", + "startTime": "2026-02-08T11:00:00-05:00", + "endTime": "2026-02-08T12:00:00-05:00", + "isDaytime": true, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -16.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 45, + "name": "", + "startTime": "2026-02-08T12:00:00-05:00", + "endTime": "2026-02-08T13:00:00-05:00", + "isDaytime": true, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 46, + "name": "", + "startTime": "2026-02-08T13:00:00-05:00", + "endTime": "2026-02-08T14:00:00-05:00", + "isDaytime": true, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 47, + "name": "", + "startTime": "2026-02-08T14:00:00-05:00", + "endTime": "2026-02-08T15:00:00-05:00", + "isDaytime": true, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 48, + "name": "", + "startTime": "2026-02-08T15:00:00-05:00", + "endTime": "2026-02-08T16:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 45 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 49, + "name": "", + "startTime": "2026-02-08T16:00:00-05:00", + "endTime": "2026-02-08T17:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "15 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 50, + "name": "", + "startTime": "2026-02-08T17:00:00-05:00", + "endTime": "2026-02-08T18:00:00-05:00", + "isDaytime": true, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 51, + "name": "", + "startTime": "2026-02-08T18:00:00-05:00", + "endTime": "2026-02-08T19:00:00-05:00", + "isDaytime": false, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 52, + "name": "", + "startTime": "2026-02-08T19:00:00-05:00", + "endTime": "2026-02-08T20:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 54 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 53, + "name": "", + "startTime": "2026-02-08T20:00:00-05:00", + "endTime": "2026-02-08T21:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 56 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 54, + "name": "", + "startTime": "2026-02-08T21:00:00-05:00", + "endTime": "2026-02-08T22:00:00-05:00", + "isDaytime": false, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 55, + "name": "", + "startTime": "2026-02-08T22:00:00-05:00", + "endTime": "2026-02-08T23:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 56, + "name": "", + "startTime": "2026-02-08T23:00:00-05:00", + "endTime": "2026-02-09T00:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 57, + "name": "", + "startTime": "2026-02-09T00:00:00-05:00", + "endTime": "2026-02-09T01:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 58, + "name": "", + "startTime": "2026-02-09T01:00:00-05:00", + "endTime": "2026-02-09T02:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 59, + "name": "", + "startTime": "2026-02-09T02:00:00-05:00", + "endTime": "2026-02-09T03:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 64 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 60, + "name": "", + "startTime": "2026-02-09T03:00:00-05:00", + "endTime": "2026-02-09T04:00:00-05:00", + "isDaytime": false, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 61, + "name": "", + "startTime": "2026-02-09T04:00:00-05:00", + "endTime": "2026-02-09T05:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 62, + "name": "", + "startTime": "2026-02-09T05:00:00-05:00", + "endTime": "2026-02-09T06:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 63, + "name": "", + "startTime": "2026-02-09T06:00:00-05:00", + "endTime": "2026-02-09T07:00:00-05:00", + "isDaytime": true, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 64, + "name": "", + "startTime": "2026-02-09T07:00:00-05:00", + "endTime": "2026-02-09T08:00:00-05:00", + "isDaytime": true, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 65, + "name": "", + "startTime": "2026-02-09T08:00:00-05:00", + "endTime": "2026-02-09T09:00:00-05:00", + "isDaytime": true, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 66, + "name": "", + "startTime": "2026-02-09T09:00:00-05:00", + "endTime": "2026-02-09T10:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 67, + "name": "", + "startTime": "2026-02-09T10:00:00-05:00", + "endTime": "2026-02-09T11:00:00-05:00", + "isDaytime": true, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 56 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 68, + "name": "", + "startTime": "2026-02-09T11:00:00-05:00", + "endTime": "2026-02-09T12:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -12.777777777777779 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 69, + "name": "", + "startTime": "2026-02-09T12:00:00-05:00", + "endTime": "2026-02-09T13:00:00-05:00", + "isDaytime": true, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -11.666666666666666 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 70, + "name": "", + "startTime": "2026-02-09T13:00:00-05:00", + "endTime": "2026-02-09T14:00:00-05:00", + "isDaytime": true, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -11.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 48 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 71, + "name": "", + "startTime": "2026-02-09T14:00:00-05:00", + "endTime": "2026-02-09T15:00:00-05:00", + "isDaytime": true, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 72, + "name": "", + "startTime": "2026-02-09T15:00:00-05:00", + "endTime": "2026-02-09T16:00:00-05:00", + "isDaytime": true, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 73, + "name": "", + "startTime": "2026-02-09T16:00:00-05:00", + "endTime": "2026-02-09T17:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 74, + "name": "", + "startTime": "2026-02-09T17:00:00-05:00", + "endTime": "2026-02-09T18:00:00-05:00", + "isDaytime": true, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "4 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 75, + "name": "", + "startTime": "2026-02-09T18:00:00-05:00", + "endTime": "2026-02-09T19:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -9.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 76, + "name": "", + "startTime": "2026-02-09T19:00:00-05:00", + "endTime": "2026-02-09T20:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -9.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 58 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 77, + "name": "", + "startTime": "2026-02-09T20:00:00-05:00", + "endTime": "2026-02-09T21:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -9.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 60 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 78, + "name": "", + "startTime": "2026-02-09T21:00:00-05:00", + "endTime": "2026-02-09T22:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 65 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 79, + "name": "", + "startTime": "2026-02-09T22:00:00-05:00", + "endTime": "2026-02-09T23:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 80, + "name": "", + "startTime": "2026-02-09T23:00:00-05:00", + "endTime": "2026-02-10T00:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 81, + "name": "", + "startTime": "2026-02-10T00:00:00-05:00", + "endTime": "2026-02-10T01:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "2 km/h", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 82, + "name": "", + "startTime": "2026-02-10T01:00:00-05:00", + "endTime": "2026-02-10T02:00:00-05:00", + "isDaytime": false, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 74 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 83, + "name": "", + "startTime": "2026-02-10T02:00:00-05:00", + "endTime": "2026-02-10T03:00:00-05:00", + "isDaytime": false, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 74 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 84, + "name": "", + "startTime": "2026-02-10T03:00:00-05:00", + "endTime": "2026-02-10T04:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 85, + "name": "", + "startTime": "2026-02-10T04:00:00-05:00", + "endTime": "2026-02-10T05:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 86, + "name": "", + "startTime": "2026-02-10T05:00:00-05:00", + "endTime": "2026-02-10T06:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 87, + "name": "", + "startTime": "2026-02-10T06:00:00-05:00", + "endTime": "2026-02-10T07:00:00-05:00", + "isDaytime": true, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 88, + "name": "", + "startTime": "2026-02-10T07:00:00-05:00", + "endTime": "2026-02-10T08:00:00-05:00", + "isDaytime": true, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 89, + "name": "", + "startTime": "2026-02-10T08:00:00-05:00", + "endTime": "2026-02-10T09:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7.777777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 74 + }, + "windSpeed": "2 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 90, + "name": "", + "startTime": "2026-02-10T09:00:00-05:00", + "endTime": "2026-02-10T10:00:00-05:00", + "isDaytime": true, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "2 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 91, + "name": "", + "startTime": "2026-02-10T10:00:00-05:00", + "endTime": "2026-02-10T11:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 63 + }, + "windSpeed": "4 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 92, + "name": "", + "startTime": "2026-02-10T11:00:00-05:00", + "endTime": "2026-02-10T12:00:00-05:00", + "isDaytime": true, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "4 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 93, + "name": "", + "startTime": "2026-02-10T12:00:00-05:00", + "endTime": "2026-02-10T13:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "6 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 94, + "name": "", + "startTime": "2026-02-10T13:00:00-05:00", + "endTime": "2026-02-10T14:00:00-05:00", + "isDaytime": true, + "temperature": 5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "6 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 95, + "name": "", + "startTime": "2026-02-10T14:00:00-05:00", + "endTime": "2026-02-10T15:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 96, + "name": "", + "startTime": "2026-02-10T15:00:00-05:00", + "endTime": "2026-02-10T16:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "6 km/h", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 97, + "name": "", + "startTime": "2026-02-10T16:00:00-05:00", + "endTime": "2026-02-10T17:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "4 km/h", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 98, + "name": "", + "startTime": "2026-02-10T17:00:00-05:00", + "endTime": "2026-02-10T18:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 99, + "name": "", + "startTime": "2026-02-10T18:00:00-05:00", + "endTime": "2026-02-10T19:00:00-05:00", + "isDaytime": false, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 100, + "name": "", + "startTime": "2026-02-10T19:00:00-05:00", + "endTime": "2026-02-10T20:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 101, + "name": "", + "startTime": "2026-02-10T20:00:00-05:00", + "endTime": "2026-02-10T21:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "2 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 102, + "name": "", + "startTime": "2026-02-10T21:00:00-05:00", + "endTime": "2026-02-10T22:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 103, + "name": "", + "startTime": "2026-02-10T22:00:00-05:00", + "endTime": "2026-02-10T23:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 104, + "name": "", + "startTime": "2026-02-10T23:00:00-05:00", + "endTime": "2026-02-11T00:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 105, + "name": "", + "startTime": "2026-02-11T00:00:00-05:00", + "endTime": "2026-02-11T01:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "2 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 106, + "name": "", + "startTime": "2026-02-11T01:00:00-05:00", + "endTime": "2026-02-11T02:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 107, + "name": "", + "startTime": "2026-02-11T02:00:00-05:00", + "endTime": "2026-02-11T03:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 108, + "name": "", + "startTime": "2026-02-11T03:00:00-05:00", + "endTime": "2026-02-11T04:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 109, + "name": "", + "startTime": "2026-02-11T04:00:00-05:00", + "endTime": "2026-02-11T05:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 88 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 110, + "name": "", + "startTime": "2026-02-11T05:00:00-05:00", + "endTime": "2026-02-11T06:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 88 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 111, + "name": "", + "startTime": "2026-02-11T06:00:00-05:00", + "endTime": "2026-02-11T07:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 112, + "name": "", + "startTime": "2026-02-11T07:00:00-05:00", + "endTime": "2026-02-11T08:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "4 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 113, + "name": "", + "startTime": "2026-02-11T08:00:00-05:00", + "endTime": "2026-02-11T09:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 114, + "name": "", + "startTime": "2026-02-11T09:00:00-05:00", + "endTime": "2026-02-11T10:00:00-05:00", + "isDaytime": true, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 115, + "name": "", + "startTime": "2026-02-11T10:00:00-05:00", + "endTime": "2026-02-11T11:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0.5555555555555556 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 82 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 116, + "name": "", + "startTime": "2026-02-11T11:00:00-05:00", + "endTime": "2026-02-11T12:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 79 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 117, + "name": "", + "startTime": "2026-02-11T12:00:00-05:00", + "endTime": "2026-02-11T13:00:00-05:00", + "isDaytime": true, + "temperature": 5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 76 + }, + "windSpeed": "9 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 118, + "name": "", + "startTime": "2026-02-11T13:00:00-05:00", + "endTime": "2026-02-11T14:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "9 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 119, + "name": "", + "startTime": "2026-02-11T14:00:00-05:00", + "endTime": "2026-02-11T15:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 120, + "name": "", + "startTime": "2026-02-11T15:00:00-05:00", + "endTime": "2026-02-11T16:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 121, + "name": "", + "startTime": "2026-02-11T16:00:00-05:00", + "endTime": "2026-02-11T17:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 122, + "name": "", + "startTime": "2026-02-11T17:00:00-05:00", + "endTime": "2026-02-11T18:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 123, + "name": "", + "startTime": "2026-02-11T18:00:00-05:00", + "endTime": "2026-02-11T19:00:00-05:00", + "isDaytime": false, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 73 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 124, + "name": "", + "startTime": "2026-02-11T19:00:00-05:00", + "endTime": "2026-02-11T20:00:00-05:00", + "isDaytime": false, + "temperature": 5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0.5555555555555556 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 73 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 125, + "name": "", + "startTime": "2026-02-11T20:00:00-05:00", + "endTime": "2026-02-11T21:00:00-05:00", + "isDaytime": false, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 73 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 126, + "name": "", + "startTime": "2026-02-11T21:00:00-05:00", + "endTime": "2026-02-11T22:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -0.5555555555555556 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 76 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 127, + "name": "", + "startTime": "2026-02-11T22:00:00-05:00", + "endTime": "2026-02-11T23:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 128, + "name": "", + "startTime": "2026-02-11T23:00:00-05:00", + "endTime": "2026-02-12T00:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 129, + "name": "", + "startTime": "2026-02-12T00:00:00-05:00", + "endTime": "2026-02-12T01:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 130, + "name": "", + "startTime": "2026-02-12T01:00:00-05:00", + "endTime": "2026-02-12T02:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 131, + "name": "", + "startTime": "2026-02-12T02:00:00-05:00", + "endTime": "2026-02-12T03:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 132, + "name": "", + "startTime": "2026-02-12T03:00:00-05:00", + "endTime": "2026-02-12T04:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 133, + "name": "", + "startTime": "2026-02-12T04:00:00-05:00", + "endTime": "2026-02-12T05:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 134, + "name": "", + "startTime": "2026-02-12T05:00:00-05:00", + "endTime": "2026-02-12T06:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 135, + "name": "", + "startTime": "2026-02-12T06:00:00-05:00", + "endTime": "2026-02-12T07:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 136, + "name": "", + "startTime": "2026-02-12T07:00:00-05:00", + "endTime": "2026-02-12T08:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 137, + "name": "", + "startTime": "2026-02-12T08:00:00-05:00", + "endTime": "2026-02-12T09:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 138, + "name": "", + "startTime": "2026-02-12T09:00:00-05:00", + "endTime": "2026-02-12T10:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 72 + }, + "windSpeed": "15 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 139, + "name": "", + "startTime": "2026-02-12T10:00:00-05:00", + "endTime": "2026-02-12T11:00:00-05:00", + "isDaytime": true, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 140, + "name": "", + "startTime": "2026-02-12T11:00:00-05:00", + "endTime": "2026-02-12T12:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 64 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 141, + "name": "", + "startTime": "2026-02-12T12:00:00-05:00", + "endTime": "2026-02-12T13:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 142, + "name": "", + "startTime": "2026-02-12T13:00:00-05:00", + "endTime": "2026-02-12T14:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 143, + "name": "", + "startTime": "2026-02-12T14:00:00-05:00", + "endTime": "2026-02-12T15:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 54 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 144, + "name": "", + "startTime": "2026-02-12T15:00:00-05:00", + "endTime": "2026-02-12T16:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 145, + "name": "", + "startTime": "2026-02-12T16:00:00-05:00", + "endTime": "2026-02-12T17:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 146, + "name": "", + "startTime": "2026-02-12T17:00:00-05:00", + "endTime": "2026-02-12T18:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "15 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 147, + "name": "", + "startTime": "2026-02-12T18:00:00-05:00", + "endTime": "2026-02-12T19:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 54 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 148, + "name": "", + "startTime": "2026-02-12T19:00:00-05:00", + "endTime": "2026-02-12T20:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 149, + "name": "", + "startTime": "2026-02-12T20:00:00-05:00", + "endTime": "2026-02-12T21:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 150, + "name": "", + "startTime": "2026-02-12T21:00:00-05:00", + "endTime": "2026-02-12T22:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.111111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 63 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 151, + "name": "", + "startTime": "2026-02-12T22:00:00-05:00", + "endTime": "2026-02-12T23:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.111111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 66 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 152, + "name": "", + "startTime": "2026-02-12T23:00:00-05:00", + "endTime": "2026-02-13T00:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.111111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 153, + "name": "", + "startTime": "2026-02-13T00:00:00-05:00", + "endTime": "2026-02-13T01:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 66 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 154, + "name": "", + "startTime": "2026-02-13T01:00:00-05:00", + "endTime": "2026-02-13T02:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 155, + "name": "", + "startTime": "2026-02-13T02:00:00-05:00", + "endTime": "2026-02-13T03:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 156, + "name": "", + "startTime": "2026-02-13T03:00:00-05:00", + "endTime": "2026-02-13T04:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7.222222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + } + ] + } +} diff --git a/tests/mocks/weather_weathergov_points.json b/tests/mocks/weather_weathergov_points.json new file mode 100644 index 0000000000..d13794899f --- /dev/null +++ b/tests/mocks/weather_weathergov_points.json @@ -0,0 +1,89 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + } + } + ], + "id": "https://api.weather.gov/points/38.8894,-77.0352", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.0352, 38.8894] + }, + "properties": { + "@id": "https://api.weather.gov/points/38.8894,-77.0352", + "@type": "wx:Point", + "cwa": "LWX", + "type": "land", + "forecastOffice": "https://api.weather.gov/offices/LWX", + "gridId": "LWX", + "gridX": 97, + "gridY": 71, + "forecast": "https://api.weather.gov/gridpoints/LWX/97,71/forecast", + "forecastHourly": "https://api.weather.gov/gridpoints/LWX/97,71/forecast/hourly", + "forecastGridData": "https://api.weather.gov/gridpoints/LWX/97,71", + "observationStations": "https://api.weather.gov/gridpoints/LWX/97,71/stations", + "relativeLocation": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.017229, 38.904103] + }, + "properties": { + "city": "Washington", + "state": "DC", + "distance": { + "unitCode": "wmoUnit:m", + "value": 2256.4628420106 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 223 + } + } + }, + "forecastZone": "https://api.weather.gov/zones/forecast/DCZ001", + "county": "https://api.weather.gov/zones/county/DCC001", + "fireWeatherZone": "https://api.weather.gov/zones/fire/DCZ001", + "timeZone": "America/New_York", + "radarStation": "KLWX" + } +} diff --git a/tests/mocks/weather_weathergov_stations.json b/tests/mocks/weather_weathergov_stations.json new file mode 100644 index 0000000000..742524a693 --- /dev/null +++ b/tests/mocks/weather_weathergov_stations.json @@ -0,0 +1,1793 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + }, + "observationStations": { + "@container": "@list", + "@type": "@id" + } + } + ], + "type": "FeatureCollection", + "features": [ + { + "id": "https://api.weather.gov/stations/KDCA", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.03417, 38.84833] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDCA", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 3.9624 + }, + "stationIdentifier": "KDCA", + "name": "Washington/Reagan National Airport, DC", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 3043.6748842539 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 140 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ054", + "county": "https://api.weather.gov/zones/county/VAC013", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ054" + } + }, + { + "id": "https://api.weather.gov/stations/KCGS", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.9223, 38.9806] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCGS", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 14.9352 + }, + "stationIdentifier": "KCGS", + "name": "College Park Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 16980.874107362 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 43 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ013", + "county": "https://api.weather.gov/zones/county/MDC033", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ013" + } + }, + { + "id": "https://api.weather.gov/stations/KADW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.85, 38.81667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KADW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 85.9536 + }, + "stationIdentifier": "KADW", + "name": "Camp Springs / Andrews Air Force Base", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 18837.139622535 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 108 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ013", + "county": "https://api.weather.gov/zones/county/MDC033", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ013" + } + }, + { + "id": "https://api.weather.gov/stations/KDAA", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.18333, 38.71667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDAA", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 21.0312 + }, + "stationIdentifier": "KDAA", + "name": "Fort Belvoir", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 20211.268967046 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 212 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ053", + "county": "https://api.weather.gov/zones/county/VAC059", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ053" + } + }, + { + "id": "https://api.weather.gov/stations/KFME", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.76667, 39.08333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFME", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 46.0248 + }, + "stationIdentifier": "KFME", + "name": "Fort Meade / Tipton", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 34568.722953871 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 46 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ014", + "county": "https://api.weather.gov/zones/county/MDC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ014" + } + }, + { + "id": "https://api.weather.gov/stations/KIAD", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.4475, 38.93472] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KIAD", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 95.0976 + }, + "stationIdentifier": "KIAD", + "name": "Washington/Dulles International Airport, DC", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 34587.939860418 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 282 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ053", + "county": "https://api.weather.gov/zones/county/VAC059", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ053" + } + }, + { + "id": "https://api.weather.gov/stations/KGAI", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.16551, 39.16957] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KGAI", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 150.876 + }, + "stationIdentifier": "KGAI", + "name": "Gaithersburg - Montgomery County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 34683.691088332 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 344 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ504", + "county": "https://api.weather.gov/zones/county/MDC031", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ504" + } + }, + { + "id": "https://api.weather.gov/stations/KHEF", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.51667, 38.71667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHEF", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 59.1312 + }, + "stationIdentifier": "KHEF", + "name": "Manassas, Manassas Regional Airport/Harry P. Davis Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 43324.86402354 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 247 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ527", + "county": "https://api.weather.gov/zones/county/VAC683", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ527" + } + }, + { + "id": "https://api.weather.gov/stations/KNYG", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.30129, 38.50326] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNYG", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 2.1336 + }, + "stationIdentifier": "KNYG", + "name": "Quantico Marine Corps Airfield - Turner Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 45906.349012092 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 207 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ527", + "county": "https://api.weather.gov/zones/county/VAC153", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ527" + } + }, + { + "id": "https://api.weather.gov/stations/KBWI", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.68404, 39.17329] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KBWI", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 41.148 + }, + "stationIdentifier": "KBWI", + "name": "Baltimore, Baltimore-Washington International Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 46680.187081868 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 43 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ014", + "county": "https://api.weather.gov/zones/county/MDC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ014" + } + }, + { + "id": "https://api.weather.gov/stations/KJYO", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.56667, 39.08333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KJYO", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 118.872 + }, + "stationIdentifier": "KJYO", + "name": "Leesburg / Godfrey", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 50093.979211268 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 298 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ506", + "county": "https://api.weather.gov/zones/county/VAC107", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ506" + } + }, + { + "id": "https://api.weather.gov/stations/KNAK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.48907, 38.99125] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNAK", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 0.9144 + }, + "stationIdentifier": "KNAK", + "name": "Annapolis, United States Naval Academy", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 50940.029746488 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 74 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ014", + "county": "https://api.weather.gov/zones/county/MDC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ014" + } + }, + { + "id": "https://api.weather.gov/stations/KDMH", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.61667, 39.28333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDMH", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 6.096 + }, + "stationIdentifier": "KDMH", + "name": "Baltimore, Inner Harbor", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 59684.848549395 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 39 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ011", + "county": "https://api.weather.gov/zones/county/MDC510", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ011" + } + }, + { + "id": "https://api.weather.gov/stations/KRMN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.45528, 38.39806] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KRMN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 64.9224 + }, + "stationIdentifier": "KRMN", + "name": "Stafford, Stafford Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 62803.929543738 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 213 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ055", + "county": "https://api.weather.gov/zones/county/VAC179", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ055" + } + }, + { + "id": "https://api.weather.gov/stations/KW29", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.33, 38.9767] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KW29", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 14.9352 + }, + "stationIdentifier": "KW29", + "name": "Bay Bridge Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 63992.315724071 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 79 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ015", + "county": "https://api.weather.gov/zones/county/MDC035", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ015" + } + }, + { + "id": "https://api.weather.gov/stations/KHWY", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.71501, 38.58765] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHWY", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 92.0496 + }, + "stationIdentifier": "KHWY", + "name": "Warrenton-Fauquier Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 65127.84173743 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 241 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ502", + "county": "https://api.weather.gov/zones/county/VAC061", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ502" + } + }, + { + "id": "https://api.weather.gov/stations/KFDK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.36982, 39.41775] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFDK", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 81.9912 + }, + "stationIdentifier": "KFDK", + "name": "Frederick Municipal Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 66692.582365459 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 336 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ004", + "county": "https://api.weather.gov/zones/county/MDC021", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ004" + } + }, + { + "id": "https://api.weather.gov/stations/KEZF", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.45, 38.26667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KEZF", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 25.908 + }, + "stationIdentifier": "KEZF", + "name": "Fredericksburg, Shannon Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 75229.907706335 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 207 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ056", + "county": "https://api.weather.gov/zones/county/VAC177", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ056" + } + }, + { + "id": "https://api.weather.gov/stations/KMTN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.41667, 39.33333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMTN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 7.0104 + }, + "stationIdentifier": "KMTN", + "name": "Baltimore / Martin", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 75581.565023524 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 46 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ011", + "county": "https://api.weather.gov/zones/county/MDC005", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ011" + } + }, + { + "id": "https://api.weather.gov/stations/K2W6", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.5501, 38.3154] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K2W6", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 43.8912 + }, + "stationIdentifier": "K2W6", + "name": "St Marys County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 75712.983389938 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 144 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ017", + "county": "https://api.weather.gov/zones/county/MDC037", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ017" + } + }, + { + "id": "https://api.weather.gov/stations/KCJR", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.85738, 38.52607] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCJR", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 89.916 + }, + "stationIdentifier": "KCJR", + "name": "Culpeper Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 79274.931842245 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 241 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ051", + "county": "https://api.weather.gov/zones/county/VAC047", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ051" + } + }, + { + "id": "https://api.weather.gov/stations/KDMW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.0077, 39.6083] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDMW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 240.4872 + }, + "stationIdentifier": "KDMW", + "name": "Carroll County Regional Jack B Poage Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 82279.382252508 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 2 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ005", + "county": "https://api.weather.gov/zones/county/MDC013", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ005" + } + }, + { + "id": "https://api.weather.gov/stations/KESN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.06667, 38.8] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KESN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 21.9456 + }, + "stationIdentifier": "KESN", + "name": "Easton Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 86100.892604455 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 94 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ019", + "county": "https://api.weather.gov/zones/county/MDC041", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ019" + } + }, + { + "id": "https://api.weather.gov/stations/KNHK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.41389, 38.27861] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNHK", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 11.8872 + }, + "stationIdentifier": "KNHK", + "name": "Patuxent River, Naval Air Station", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 86239.928763087 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 139 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ017", + "county": "https://api.weather.gov/zones/county/MDC037", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ017" + } + }, + { + "id": "https://api.weather.gov/stations/KRSP", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.468, 39.645] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KRSP", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 561.1368 + }, + "stationIdentifier": "KRSP", + "name": "Camp David", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 93237.282967055 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 337 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ004", + "county": "https://api.weather.gov/zones/county/MDC021", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ004" + } + }, + { + "id": "https://api.weather.gov/stations/KNUI", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.42, 38.14889] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNUI", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 6.096 + }, + "stationIdentifier": "KNUI", + "name": "St. Inigoes, Webster Field, Naval Electronic Systems Engineering Activity", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 97399.572233461 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 145 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ017", + "county": "https://api.weather.gov/zones/county/MDC037", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ017" + } + }, + { + "id": "https://api.weather.gov/stations/KMRB", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.975, 39.40372] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMRB", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 163.9824 + }, + "stationIdentifier": "KMRB", + "name": "Eastern WV Regional Airport/Shepherd Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 99011.527250549 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 307 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ052", + "county": "https://api.weather.gov/zones/county/WVC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ052" + } + }, + { + "id": "https://api.weather.gov/stations/KOKV", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.15, 39.15] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KOKV", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 221.8944 + }, + "stationIdentifier": "KOKV", + "name": "Winchester Regional", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 99483.258804184 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 288 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ028", + "county": "https://api.weather.gov/zones/county/VAC069", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ028" + } + }, + { + "id": "https://api.weather.gov/stations/KAPG", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.16667, 39.46667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KAPG", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 17.9832 + }, + "stationIdentifier": "KAPG", + "name": "Phillips Army Air Field / Aberdeen", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 101486.28902381 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 48 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ508", + "county": "https://api.weather.gov/zones/county/MDC025", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ508" + } + }, + { + "id": "https://api.weather.gov/stations/KFRR", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.2535, 38.9175] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFRR", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 216.1032 + }, + "stationIdentifier": "KFRR", + "name": "Front Royal-warren County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 103711.79430435 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 273 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ030", + "county": "https://api.weather.gov/zones/county/VAC187", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ030" + } + }, + { + "id": "https://api.weather.gov/stations/K0W3", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.20297, 39.5682] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K0W3", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 125.5776 + }, + "stationIdentifier": "K0W3", + "name": "Harford County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 106997.11216766 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 43 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ508", + "county": "https://api.weather.gov/zones/county/MDC025", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ508" + } + }, + { + "id": "https://api.weather.gov/stations/KHGR", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.73, 39.70583] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHGR", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 213.9696 + }, + "stationIdentifier": "KHGR", + "name": "Hagerstown, Washington County Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 109586.26730048 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 328 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ003", + "county": "https://api.weather.gov/zones/county/MDC043", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ003" + } + }, + { + "id": "https://api.weather.gov/stations/KOMH", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.04556, 38.24722] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KOMH", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 142.0368 + }, + "stationIdentifier": "KOMH", + "name": "Orange, Orange County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 110351.40846398 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 231 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ050", + "county": "https://api.weather.gov/zones/county/VAC137", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ050" + } + }, + { + "id": "https://api.weather.gov/stations/K7W4", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.7459, 37.9658] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K7W4", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 106.9848 + }, + "stationIdentifier": "K7W4", + "name": "Lake Anna Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 117039.9799578 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 211 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ510", + "county": "https://api.weather.gov/zones/county/VAC109", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ510" + } + }, + { + "id": "https://api.weather.gov/stations/KTHV", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.87694, 39.91944] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KTHV", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 145.9992 + }, + "stationIdentifier": "KTHV", + "name": "York, York Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 117785.75882145 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 7 + }, + "forecast": "https://api.weather.gov/zones/forecast/PAZ065", + "county": "https://api.weather.gov/zones/county/PAC133", + "fireWeatherZone": "https://api.weather.gov/zones/fire/PAZ065" + } + }, + { + "id": "https://api.weather.gov/stations/KLKU", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.97028, 38.00972] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLKU", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 149.9616 + }, + "stationIdentifier": "KLKU", + "name": "Louisa, Louisa County Airport/Freeman Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 124364.21495608 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 220 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ510", + "county": "https://api.weather.gov/zones/county/VAC109", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ510" + } + }, + { + "id": "https://api.weather.gov/stations/KGVE", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.1658, 38.156] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KGVE", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 138.0744 + }, + "stationIdentifier": "KGVE", + "name": "Gordonsville Municipal Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 124909.61245374 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 230 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ050", + "county": "https://api.weather.gov/zones/county/VAC137", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ050" + } + }, + { + "id": "https://api.weather.gov/stations/KOFP", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.43444, 37.70806] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KOFP", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 61.8744 + }, + "stationIdentifier": "KOFP", + "name": "Ashland, Hanover County Municipal Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 133267.46930022 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 194 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ511", + "county": "https://api.weather.gov/zones/county/VAC085", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ511" + } + }, + { + "id": "https://api.weather.gov/stations/K8W2", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.7081, 38.6557] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K8W2", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 297.18 + }, + "stationIdentifier": "K8W2", + "name": "New Market Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 145135.20416818 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 261 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ027", + "county": "https://api.weather.gov/zones/county/VAC171", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ027" + } + }, + { + "id": "https://api.weather.gov/stations/KCHO", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.45516, 38.13738] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCHO", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 195.072 + }, + "stationIdentifier": "KCHO", + "name": "Charlottesville-Albemarle Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 146394.21605562 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 236 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ037", + "county": "https://api.weather.gov/zones/county/VAC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ037" + } + }, + { + "id": "https://api.weather.gov/stations/KRIC", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.32333, 37.51111] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KRIC", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 50.9016 + }, + "stationIdentifier": "KRIC", + "name": "Richmond, Richmond International Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 152812.69388052 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 188 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ516", + "county": "https://api.weather.gov/zones/county/VAC087", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ516" + } + }, + { + "id": "https://api.weather.gov/stations/KILG", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-75.60567, 39.67442] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KILG", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 21.9456 + }, + "stationIdentifier": "KILG", + "name": "Wilmington Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 153674.36438971 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 53 + }, + "forecast": "https://api.weather.gov/zones/forecast/DEZ001", + "county": "https://api.weather.gov/zones/county/DEC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/DEZ001" + } + }, + { + "id": "https://api.weather.gov/stations/KLNS", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.29446, 40.12058] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLNS", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 121.0056 + }, + "stationIdentifier": "KLNS", + "name": "Lancaster, Lancaster Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 153739.95516367 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 24 + }, + "forecast": "https://api.weather.gov/zones/forecast/PAZ066", + "county": "https://api.weather.gov/zones/county/PAC071", + "fireWeatherZone": "https://api.weather.gov/zones/fire/PAZ066" + } + }, + { + "id": "https://api.weather.gov/stations/KCBE", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.76083, 39.61528] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCBE", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 235.9152 + }, + "stationIdentifier": "KCBE", + "name": "Cumberland, Greater Cumberland Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 168568.42779476 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 300 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ504", + "county": "https://api.weather.gov/zones/county/WVC057", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ504" + } + }, + { + "id": "https://api.weather.gov/stations/KSHD", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.9, 38.26667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KSHD", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 366.0648 + }, + "stationIdentifier": "KSHD", + "name": "Staunton / Shenandoah", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 173695.8603158 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 247 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ025", + "county": "https://api.weather.gov/zones/county/VAC015", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ025" + } + }, + { + "id": "https://api.weather.gov/stations/KVBW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.96033, 38.36674] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KVBW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 355.092 + }, + "stationIdentifier": "KVBW", + "name": "Bridgewater Air Park", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 174565.90990218 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 251 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ026", + "county": "https://api.weather.gov/zones/county/VAC165", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ026" + } + }, + { + "id": "https://api.weather.gov/stations/KMFV", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-75.76667, 37.65] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMFV", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 14.9352 + }, + "stationIdentifier": "KMFV", + "name": "Melfa / Accomack Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 176261.84200174 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 139 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ099", + "county": "https://api.weather.gov/zones/county/VAC001", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ099" + } + }, + { + "id": "https://api.weather.gov/stations/KW13", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.9444, 38.0769] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KW13", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 437.9976 + }, + "stationIdentifier": "KW13", + "name": "Eagles Nest Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 186456.91815645 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 242 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ025", + "county": "https://api.weather.gov/zones/county/VAC015", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ025" + } + }, + { + "id": "https://api.weather.gov/stations/KFVX", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.43333, 37.35] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFVX", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 124.968 + }, + "stationIdentifier": "KFVX", + "name": "Farmville", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 207471.35283371 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 215 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ061", + "county": "https://api.weather.gov/zones/county/VAC049", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ061" + } + }, + { + "id": "https://api.weather.gov/stations/K2G4", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.3394, 39.5803] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K2G4", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 893.9784 + }, + "stationIdentifier": "K2G4", + "name": "Garrett County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 211917.76730527 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 292 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ509", + "county": "https://api.weather.gov/zones/county/MDC023", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ509" + } + }, + { + "id": "https://api.weather.gov/stations/K2G9", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.015, 40.0389] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K2G9", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 693.42 + }, + "stationIdentifier": "K2G9", + "name": "Somerset County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 212550.3935379 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 308 + }, + "forecast": "https://api.weather.gov/zones/forecast/PAZ033", + "county": "https://api.weather.gov/zones/county/PAC111", + "fireWeatherZone": "https://api.weather.gov/zones/fire/PAZ033" + } + }, + { + "id": "https://api.weather.gov/stations/KEKN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.85278, 38.88528] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KEKN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 605.028 + }, + "stationIdentifier": "KEKN", + "name": "Elkins, Elkins-Randolph County-Jennings Randolph Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 242035.43677875 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 271 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ525", + "county": "https://api.weather.gov/zones/county/WVC083", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ525" + } + }, + { + "id": "https://api.weather.gov/stations/KLYH", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.20667, 37.32083] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLYH", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 284.988 + }, + "stationIdentifier": "KLYH", + "name": "Lynchburg, Lynchburg Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 255021.94789795 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 228 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ045", + "county": "https://api.weather.gov/zones/county/VAC031", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ045" + } + }, + { + "id": "https://api.weather.gov/stations/KMGW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.92065, 39.64985] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMGW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 373.9896 + }, + "stationIdentifier": "KMGW", + "name": "Morgantown Municipal-Hart Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 261388.09543822 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 290 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ509", + "county": "https://api.weather.gov/zones/county/WVC061", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ509" + } + }, + { + "id": "https://api.weather.gov/stations/KHSP", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.83333, 37.95] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHSP", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 1156.1064 + }, + "stationIdentifier": "KHSP", + "name": "Hot Springs / Ingalls", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 262623.28570565 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 247 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ020", + "county": "https://api.weather.gov/zones/county/VAC017", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ020" + } + }, + { + "id": "https://api.weather.gov/stations/KROA", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.97417, 37.31694] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KROA", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 358.14 + }, + "stationIdentifier": "KROA", + "name": "Roanoke, Roanoke Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 308160.42662802 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 236 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ022", + "county": "https://api.weather.gov/zones/county/VAC770", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ022" + } + } + ], + "observationStations": [ + "https://api.weather.gov/stations/KDCA", + "https://api.weather.gov/stations/KCGS", + "https://api.weather.gov/stations/KADW", + "https://api.weather.gov/stations/KDAA", + "https://api.weather.gov/stations/KFME", + "https://api.weather.gov/stations/KIAD", + "https://api.weather.gov/stations/KGAI", + "https://api.weather.gov/stations/KHEF", + "https://api.weather.gov/stations/KNYG", + "https://api.weather.gov/stations/KBWI", + "https://api.weather.gov/stations/KJYO", + "https://api.weather.gov/stations/KNAK", + "https://api.weather.gov/stations/KDMH", + "https://api.weather.gov/stations/KRMN", + "https://api.weather.gov/stations/KW29", + "https://api.weather.gov/stations/KHWY", + "https://api.weather.gov/stations/KFDK", + "https://api.weather.gov/stations/KEZF", + "https://api.weather.gov/stations/KMTN", + "https://api.weather.gov/stations/K2W6", + "https://api.weather.gov/stations/KCJR", + "https://api.weather.gov/stations/KDMW", + "https://api.weather.gov/stations/KESN", + "https://api.weather.gov/stations/KNHK", + "https://api.weather.gov/stations/KRSP", + "https://api.weather.gov/stations/KNUI", + "https://api.weather.gov/stations/KMRB", + "https://api.weather.gov/stations/KOKV", + "https://api.weather.gov/stations/KAPG", + "https://api.weather.gov/stations/KFRR", + "https://api.weather.gov/stations/K0W3", + "https://api.weather.gov/stations/KHGR", + "https://api.weather.gov/stations/KOMH", + "https://api.weather.gov/stations/K7W4", + "https://api.weather.gov/stations/KTHV", + "https://api.weather.gov/stations/KLKU", + "https://api.weather.gov/stations/KGVE", + "https://api.weather.gov/stations/KOFP", + "https://api.weather.gov/stations/K8W2", + "https://api.weather.gov/stations/KCHO", + "https://api.weather.gov/stations/KRIC", + "https://api.weather.gov/stations/KILG", + "https://api.weather.gov/stations/KLNS", + "https://api.weather.gov/stations/KCBE", + "https://api.weather.gov/stations/KSHD", + "https://api.weather.gov/stations/KVBW", + "https://api.weather.gov/stations/KMFV", + "https://api.weather.gov/stations/KW13", + "https://api.weather.gov/stations/KFVX", + "https://api.weather.gov/stations/K2G4", + "https://api.weather.gov/stations/K2G9", + "https://api.weather.gov/stations/KEKN", + "https://api.weather.gov/stations/KLYH", + "https://api.weather.gov/stations/KMGW", + "https://api.weather.gov/stations/KHSP", + "https://api.weather.gov/stations/KROA" + ], + "pagination": { + "next": "https://api.weather.gov/stations?id%5B0%5D=K0W3&id%5B1%5D=K2G4&id%5B2%5D=K2G9&id%5B3%5D=K2W6&id%5B4%5D=K7W4&id%5B5%5D=K8W2&id%5B6%5D=KADW&id%5B7%5D=KAPG&id%5B8%5D=KBWI&id%5B9%5D=KCBE&id%5B10%5D=KCGS&id%5B11%5D=KCHO&id%5B12%5D=KCJR&id%5B13%5D=KDAA&id%5B14%5D=KDCA&id%5B15%5D=KDMH&id%5B16%5D=KDMW&id%5B17%5D=KEKN&id%5B18%5D=KESN&id%5B19%5D=KEZF&id%5B20%5D=KFDK&id%5B21%5D=KFME&id%5B22%5D=KFRR&id%5B23%5D=KFVX&id%5B24%5D=KGAI&id%5B25%5D=KGVE&id%5B26%5D=KHEF&id%5B27%5D=KHGR&id%5B28%5D=KHSP&id%5B29%5D=KHWY&id%5B30%5D=KIAD&id%5B31%5D=KILG&id%5B32%5D=KJYO&id%5B33%5D=KLKU&id%5B34%5D=KLNS&id%5B35%5D=KLYH&id%5B36%5D=KMFV&id%5B37%5D=KMGW&id%5B38%5D=KMRB&id%5B39%5D=KMTN&id%5B40%5D=KNAK&id%5B41%5D=KNHK&id%5B42%5D=KNUI&id%5B43%5D=KNYG&id%5B44%5D=KOFP&id%5B45%5D=KOKV&id%5B46%5D=KOMH&id%5B47%5D=KRIC&id%5B48%5D=KRMN&id%5B49%5D=KROA&id%5B50%5D=KRSP&id%5B51%5D=KSHD&id%5B52%5D=KTHV&id%5B53%5D=KVBW&id%5B54%5D=KW13&id%5B55%5D=KW29&cursor=eyJzIjo1MDB9" + } +} diff --git a/tests/mocks/weather_yr.json b/tests/mocks/weather_yr.json new file mode 100644 index 0000000000..3d7dc66d80 --- /dev/null +++ b/tests/mocks/weather_yr.json @@ -0,0 +1,707 @@ +{ + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [10.7522, 59.9139, 5] }, + "properties": { + "meta": { + "updated_at": "2026-02-06T20:27:06Z", + "units": { "air_pressure_at_sea_level": "hPa", "air_temperature": "celsius", "cloud_area_fraction": "%", "precipitation_amount": "mm", "relative_humidity": "%", "wind_from_direction": "degrees", "wind_speed": "m/s" } + }, + "timeseries": [ + { + "time": "2026-02-06T21:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1014.6, "air_temperature": -5.8, "cloud_area_fraction": 100.0, "relative_humidity": 66.5, "wind_from_direction": 37.0, "wind_speed": 6.0 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.5 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 3.5 } } + } + }, + { + "time": "2026-02-06T22:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1014.8, "air_temperature": -5.9, "cloud_area_fraction": 100.0, "relative_humidity": 70.5, "wind_from_direction": 39.0, "wind_speed": 6.4 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.7 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 3.3 } } + } + }, + { + "time": "2026-02-06T23:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.1, "air_temperature": -5.9, "cloud_area_fraction": 100.0, "relative_humidity": 73.3, "wind_from_direction": 41.0, "wind_speed": 6.6 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.8 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 2.6 } } + } + }, + { + "time": "2026-02-07T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.4, "air_temperature": -5.8, "cloud_area_fraction": 100.0, "relative_humidity": 74.6, "wind_from_direction": 40.0, "wind_speed": 6.9 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.6 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 1.9 } } + } + }, + { + "time": "2026-02-07T01:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.5, "air_temperature": -5.7, "cloud_area_fraction": 100.0, "relative_humidity": 75.5, "wind_from_direction": 41.0, "wind_speed": 6.9 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.5 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 1.4 } } + } + }, + { + "time": "2026-02-07T02:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.7, "air_temperature": -5.5, "cloud_area_fraction": 100.0, "relative_humidity": 76.2, "wind_from_direction": 38.0, "wind_speed": 5.6 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.3 } }, + "next_6_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.9 } } + } + }, + { + "time": "2026-02-07T03:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.7, "air_temperature": -5.3, "cloud_area_fraction": 100.0, "relative_humidity": 76.6, "wind_from_direction": 37.0, "wind_speed": 5.2 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.2 } }, + "next_6_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.6 } } + } + }, + { + "time": "2026-02-07T04:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.7, "air_temperature": -5.2, "cloud_area_fraction": 100.0, "relative_humidity": 76.1, "wind_from_direction": 36.0, "wind_speed": 4.8 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.2 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T05:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.9, "air_temperature": -5.1, "cloud_area_fraction": 100.0, "relative_humidity": 75.6, "wind_from_direction": 35.0, "wind_speed": 4.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1016.5, "air_temperature": -5.0, "cloud_area_fraction": 100.0, "relative_humidity": 74.7, "wind_from_direction": 33.0, "wind_speed": 4.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T07:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.2, "air_temperature": -4.9, "cloud_area_fraction": 100.0, "relative_humidity": 73.7, "wind_from_direction": 35.0, "wind_speed": 4.3 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T08:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.9, "air_temperature": -4.7, "cloud_area_fraction": 99.8, "relative_humidity": 71.7, "wind_from_direction": 38.0, "wind_speed": 4.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T09:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1018.5, "air_temperature": -4.5, "cloud_area_fraction": 99.8, "relative_humidity": 70.2, "wind_from_direction": 43.0, "wind_speed": 5.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T10:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.0, "air_temperature": -4.1, "cloud_area_fraction": 100.0, "relative_humidity": 69.5, "wind_from_direction": 45.0, "wind_speed": 5.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T11:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.2, "air_temperature": -3.7, "cloud_area_fraction": 99.9, "relative_humidity": 68.7, "wind_from_direction": 45.0, "wind_speed": 5.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.2, "air_temperature": -3.1, "cloud_area_fraction": 93.4, "relative_humidity": 63.4, "wind_from_direction": 43.0, "wind_speed": 5.8 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T13:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.3, "air_temperature": -2.8, "cloud_area_fraction": 83.1, "relative_humidity": 59.5, "wind_from_direction": 46.0, "wind_speed": 6.1 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T14:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.5, "air_temperature": -2.7, "cloud_area_fraction": 79.7, "relative_humidity": 57.7, "wind_from_direction": 43.0, "wind_speed": 5.9 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T15:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.8, "air_temperature": -2.9, "cloud_area_fraction": 70.8, "relative_humidity": 56.6, "wind_from_direction": 40.0, "wind_speed": 5.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T16:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1020.3, "air_temperature": -3.6, "cloud_area_fraction": 55.6, "relative_humidity": 55.7, "wind_from_direction": 42.0, "wind_speed": 5.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T17:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1020.8, "air_temperature": -4.3, "cloud_area_fraction": 43.1, "relative_humidity": 54.0, "wind_from_direction": 43.0, "wind_speed": 5.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1021.5, "air_temperature": -4.8, "cloud_area_fraction": 27.4, "relative_humidity": 52.3, "wind_from_direction": 42.0, "wind_speed": 5.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T19:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1022.1, "air_temperature": -5.2, "cloud_area_fraction": 19.3, "relative_humidity": 53.2, "wind_from_direction": 43.0, "wind_speed": 5.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T20:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1022.7, "air_temperature": -5.5, "cloud_area_fraction": 10.2, "relative_humidity": 55.0, "wind_from_direction": 43.0, "wind_speed": 5.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T21:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.5, "air_temperature": -5.6, "cloud_area_fraction": 6.8, "relative_humidity": 61.3, "wind_from_direction": 43.0, "wind_speed": 5.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T22:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.2, "air_temperature": -5.9, "cloud_area_fraction": 38.5, "relative_humidity": 71.4, "wind_from_direction": 38.0, "wind_speed": 4.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T23:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.7, "air_temperature": -6.2, "cloud_area_fraction": 75.2, "relative_humidity": 77.8, "wind_from_direction": 36.0, "wind_speed": 4.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.2, "air_temperature": -6.4, "cloud_area_fraction": 79.6, "relative_humidity": 79.8, "wind_from_direction": 36.0, "wind_speed": 3.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T01:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.5, "air_temperature": -6.5, "cloud_area_fraction": 77.6, "relative_humidity": 80.0, "wind_from_direction": 34.0, "wind_speed": 3.1 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T02:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.5, "air_temperature": -6.5, "cloud_area_fraction": 71.4, "relative_humidity": 79.7, "wind_from_direction": 32.0, "wind_speed": 3.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T03:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.3, "air_temperature": -6.7, "cloud_area_fraction": 63.1, "relative_humidity": 79.9, "wind_from_direction": 32.0, "wind_speed": 3.3 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T04:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.3, "air_temperature": -7.1, "cloud_area_fraction": 62.1, "relative_humidity": 80.4, "wind_from_direction": 33.0, "wind_speed": 3.1 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T05:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.2, "air_temperature": -7.5, "cloud_area_fraction": 65.0, "relative_humidity": 82.2, "wind_from_direction": 45.0, "wind_speed": 2.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.5, "air_temperature": -7.7, "cloud_area_fraction": 77.7, "relative_humidity": 82.7, "wind_from_direction": 48.0, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T07:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.8, "air_temperature": -7.8, "cloud_area_fraction": 84.5, "relative_humidity": 82.2, "wind_from_direction": 48.0, "wind_speed": 2.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T08:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.2, "air_temperature": -7.6, "cloud_area_fraction": 82.8, "relative_humidity": 80.9, "wind_from_direction": 48.0, "wind_speed": 3.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T09:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.4, "air_temperature": -6.9, "cloud_area_fraction": 77.9, "relative_humidity": 78.9, "wind_from_direction": 46.0, "wind_speed": 3.3 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T10:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.3, "air_temperature": -6.2, "cloud_area_fraction": 82.3, "relative_humidity": 77.0, "wind_from_direction": 43.0, "wind_speed": 3.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T11:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.3, "air_temperature": -5.5, "cloud_area_fraction": 93.0, "relative_humidity": 76.6, "wind_from_direction": 49.0, "wind_speed": 3.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.1, "air_temperature": -5.1, "cloud_area_fraction": 98.9, "relative_humidity": 76.2, "wind_from_direction": 47.0, "wind_speed": 2.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T13:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.7, "air_temperature": -4.8, "cloud_area_fraction": 99.4, "relative_humidity": 76.2, "wind_from_direction": 50.0, "wind_speed": 2.3 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T14:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.4, "air_temperature": -4.8, "cloud_area_fraction": 95.5, "relative_humidity": 76.3, "wind_from_direction": 56.0, "wind_speed": 2.5 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T15:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.1, "air_temperature": -5.4, "cloud_area_fraction": 84.9, "relative_humidity": 77.2, "wind_from_direction": 56.0, "wind_speed": 2.6 } }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T16:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.8, "air_temperature": -6.1, "cloud_area_fraction": 57.9, "relative_humidity": 78.9, "wind_from_direction": 48.0, "wind_speed": 2.7 } }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T17:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.5, "air_temperature": -6.5, "cloud_area_fraction": 50.7, "relative_humidity": 81.3, "wind_from_direction": 38.0, "wind_speed": 2.5 } }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.3, "air_temperature": -6.9, "cloud_area_fraction": 72.7, "relative_humidity": 82.2, "wind_from_direction": 38.0, "wind_speed": 2.5 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T19:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.3, "air_temperature": -6.9, "cloud_area_fraction": 89.8, "relative_humidity": 81.9, "wind_from_direction": 44.0, "wind_speed": 1.9 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T20:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.2, "air_temperature": -7.0, "cloud_area_fraction": 96.6, "relative_humidity": 81.3, "wind_from_direction": 39.0, "wind_speed": 2.3 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T21:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.1, "air_temperature": -6.7, "cloud_area_fraction": 97.2, "relative_humidity": 79.9, "wind_from_direction": 40.0, "wind_speed": 2.8 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T22:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.8, "air_temperature": -6.7, "cloud_area_fraction": 97.6, "relative_humidity": 80.3, "wind_from_direction": 50.0, "wind_speed": 2.6 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T23:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.4, "air_temperature": -6.7, "cloud_area_fraction": 93.5, "relative_humidity": 80.7, "wind_from_direction": 53.0, "wind_speed": 2.3 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.1, "air_temperature": -7.1, "cloud_area_fraction": 80.0, "relative_humidity": 81.2, "wind_from_direction": 60.0, "wind_speed": 2.3 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.1, "air_temperature": -4.4, "cloud_area_fraction": 99.2, "relative_humidity": 85.9, "wind_from_direction": 339.8, "wind_speed": 1.1 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.8, "air_temperature": -4.3, "cloud_area_fraction": 100.0, "relative_humidity": 72.3, "wind_from_direction": 285.3, "wind_speed": 0.7 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1014.7, "air_temperature": -6.8, "cloud_area_fraction": 95.7, "relative_humidity": 82.1, "wind_from_direction": 346.8, "wind_speed": 0.6 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1012.9, "air_temperature": -8.8, "cloud_area_fraction": 97.7, "relative_humidity": 83.2, "wind_from_direction": 15.8, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1009.9, "air_temperature": -5.8, "cloud_area_fraction": 93.7, "relative_humidity": 82.2, "wind_from_direction": 22.4, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.5, "air_temperature": -3.5, "cloud_area_fraction": 100.0, "relative_humidity": 71.4, "wind_from_direction": 202.3, "wind_speed": 0.9 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1004.3, "air_temperature": -3.0, "cloud_area_fraction": 100.0, "relative_humidity": 81.9, "wind_from_direction": 22.3, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1002.5, "air_temperature": -2.3, "cloud_area_fraction": 100.0, "relative_humidity": 85.0, "wind_from_direction": 28.5, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1000.9, "air_temperature": -3.2, "cloud_area_fraction": 100.0, "relative_humidity": 85.5, "wind_from_direction": 28.1, "wind_speed": 1.6 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 999.8, "air_temperature": -2.0, "cloud_area_fraction": 100.0, "relative_humidity": 74.9, "wind_from_direction": 56.3, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.8, "air_temperature": -2.4, "cloud_area_fraction": 82.0, "relative_humidity": 77.8, "wind_from_direction": 29.5, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.3, "air_temperature": -2.9, "cloud_area_fraction": 100.0, "relative_humidity": 83.4, "wind_from_direction": 33.1, "wind_speed": 2.5 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.4, "air_temperature": -3.9, "cloud_area_fraction": 100.0, "relative_humidity": 83.0, "wind_from_direction": 24.1, "wind_speed": 2.5 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.9, "air_temperature": -3.3, "cloud_area_fraction": 99.6, "relative_humidity": 73.0, "wind_from_direction": 54.4, "wind_speed": 2.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 999.9, "air_temperature": -4.3, "cloud_area_fraction": 98.0, "relative_humidity": 81.3, "wind_from_direction": 24.0, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1001.9, "air_temperature": -4.6, "cloud_area_fraction": 39.8, "relative_humidity": 80.6, "wind_from_direction": 23.4, "wind_speed": 2.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1004.1, "air_temperature": -7.4, "cloud_area_fraction": 36.3, "relative_humidity": 81.8, "wind_from_direction": 21.9, "wind_speed": 1.9 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1005.7, "air_temperature": -5.8, "cloud_area_fraction": 100.0, "relative_humidity": 73.2, "wind_from_direction": 33.1, "wind_speed": 1.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1004.7, "air_temperature": -5.0, "cloud_area_fraction": 0.0, "relative_humidity": 76.6, "wind_from_direction": 20.2, "wind_speed": 1.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.8, "air_temperature": -7.8, "cloud_area_fraction": 6.2, "relative_humidity": 78.8, "wind_from_direction": 23.1, "wind_speed": 1.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.4, "air_temperature": -11.8, "cloud_area_fraction": 21.9, "relative_humidity": 79.9, "wind_from_direction": 21.8, "wind_speed": 1.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.5, "air_temperature": -6.3, "cloud_area_fraction": 100.0, "relative_humidity": 70.5, "wind_from_direction": 25.3, "wind_speed": 1.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1008.0, "air_temperature": -5.5, "cloud_area_fraction": 100.0, "relative_humidity": 76.6, "wind_from_direction": 22.4, "wind_speed": 1.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1009.5, "air_temperature": -6.4, "cloud_area_fraction": 25.4, "relative_humidity": 76.8, "wind_from_direction": 18.6, "wind_speed": 1.5 } }, + "next_12_hours": { "summary": { "symbol_code": "fair_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1012.1, "air_temperature": -11.2, "cloud_area_fraction": 16.8, "relative_humidity": 79.5, "wind_from_direction": 17.5, "wind_speed": 1.6 } }, + "next_12_hours": { "summary": { "symbol_code": "clearsky_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1013.1, "air_temperature": -5.3, "cloud_area_fraction": 2.7, "relative_humidity": 59.4, "wind_from_direction": 197.5, "wind_speed": 1.2 } }, + "next_12_hours": { "summary": { "symbol_code": "clearsky_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "clearsky_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.2, "air_temperature": -7.4, "cloud_area_fraction": 2.3, "relative_humidity": 74.9, "wind_from_direction": 22.8, "wind_speed": 1.4 } }, + "next_12_hours": { "summary": { "symbol_code": "fair_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.9, "air_temperature": -9.3, "cloud_area_fraction": 2.3, "relative_humidity": 78.8, "wind_from_direction": 22.1, "wind_speed": 1.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.5, "air_temperature": -8.6, "cloud_area_fraction": 100.0, "relative_humidity": 82.1, "wind_from_direction": 17.7, "wind_speed": 1.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1012.1, "air_temperature": -3.0, "cloud_area_fraction": 3.9, "relative_humidity": 62.3, "wind_from_direction": 30.4, "wind_speed": 1.4 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T18:00:00Z", + "data": { "instant": { "details": { "air_pressure_at_sea_level": 1017.1, "air_temperature": -5.9, "cloud_area_fraction": 100.0, "relative_humidity": 82.0, "wind_from_direction": 26.6, "wind_speed": 1.9 } } } + } + ] + } +} diff --git a/tests/unit/functions/server_functions_spec.js b/tests/unit/functions/server_functions_spec.js index 7596577309..799906260f 100644 --- a/tests/unit/functions/server_functions_spec.js +++ b/tests/unit/functions/server_functions_spec.js @@ -147,6 +147,7 @@ describe("server_functions tests", () => { }); it("Gets User-Agent from configuration", async () => { + const previousConfig = global.config; global.config = {}; let userAgent; @@ -160,6 +161,8 @@ describe("server_functions tests", () => { global.config.userAgent = () => "Mozilla/5.0 (Bar)"; userAgent = getUserAgent(); expect(userAgent).toBe("Mozilla/5.0 (Bar)"); + + global.config = previousConfig; }); }); }); diff --git a/tests/unit/modules/default/utils_spec.js b/tests/unit/modules/default/utils_spec.js index 4af7db6765..efea6e28e7 100644 --- a/tests/unit/modules/default/utils_spec.js +++ b/tests/unit/modules/default/utils_spec.js @@ -1,111 +1,9 @@ global.moment = require("moment-timezone"); const defaults = require("../../../../js/defaults"); -const { performWebRequest, formatTime } = require(`../../../../${defaults.defaultModulesDir}/utils`); +const { formatTime } = require(`../../../../${defaults.defaultModulesDir}/utils`); describe("Default modules utils tests", () => { - describe("performWebRequest", () => { - const locationHost = "localhost:8080"; - const locationProtocol = "http"; - - let fetchResponse; - let fetchMock; - let urlToCall; - - beforeEach(() => { - fetchResponse = new Response(); - global.fetch = vi.fn(() => Promise.resolve(fetchResponse)); - fetchMock = global.fetch; - }); - - describe("When using cors proxy", () => { - Object.defineProperty(global, "location", { - value: { - host: locationHost, - protocol: locationProtocol - } - }); - - it("Calls correct URL once", async () => { - urlToCall = "http://www.test.com/path?param1=value1"; - - await performWebRequest(urlToCall, "json", true); - - expect(fetchMock.mock.calls).toHaveLength(1); - expect(fetchMock.mock.calls[0][0]).toBe(`${locationProtocol}//${locationHost}/cors?url=${urlToCall}`); - }); - - it("Sends correct headers", async () => { - urlToCall = "http://www.test.com/path?param1=value1"; - - const headers = [ - { name: "header1", value: "value1" }, - { name: "header2", value: "value2" } - ]; - - await performWebRequest(urlToCall, "json", true, headers); - - expect(fetchMock.mock.calls).toHaveLength(1); - expect(fetchMock.mock.calls[0][0]).toBe(`${locationProtocol}//${locationHost}/cors?sendheaders=header1:value1,header2:value2&url=${urlToCall}`); - }); - }); - - describe("When not using cors proxy", () => { - it("Calls correct URL once", async () => { - urlToCall = "http://www.test.com/path?param1=value1"; - - await performWebRequest(urlToCall); - - expect(fetchMock.mock.calls).toHaveLength(1); - expect(fetchMock.mock.calls[0][0]).toBe(urlToCall); - }); - - it("Sends correct headers", async () => { - urlToCall = "http://www.test.com/path?param1=value1"; - const headers = [ - { name: "header1", value: "value1" }, - { name: "header2", value: "value2" } - ]; - - await performWebRequest(urlToCall, "json", false, headers); - - const expectedHeaders = { headers: { header1: "value1", header2: "value2" } }; - expect(fetchMock.mock.calls).toHaveLength(1); - expect(fetchMock.mock.calls[0][1]).toStrictEqual(expectedHeaders); - }); - }); - - describe("When receiving json format", () => { - it("Returns undefined when no data is received", async () => { - urlToCall = "www.test.com"; - - const response = await performWebRequest(urlToCall); - - expect(response).toBeUndefined(); - }); - - it("Returns object when data is received", async () => { - urlToCall = "www.test.com"; - fetchResponse = new Response("{\"body\": \"some content\"}"); - - const response = await performWebRequest(urlToCall); - - expect(response.body).toBe("some content"); - }); - - it("Returns expected headers when data is received", async () => { - urlToCall = "www.test.com"; - fetchResponse = new Response("{\"body\": \"some content\"}", { headers: { header1: "value1", header2: "value2" } }); - - const response = await performWebRequest(urlToCall, "json", false, undefined, ["header1"]); - - expect(response.headers).toHaveLength(1); - expect(response.headers[0].name).toBe("header1"); - expect(response.headers[0].value).toBe("value1"); - }); - }); - }); - describe("formatTime", () => { const time = new Date(); diff --git a/tests/unit/modules/default/weather/provider_utils_spec.js b/tests/unit/modules/default/weather/provider_utils_spec.js new file mode 100644 index 0000000000..511a84340f --- /dev/null +++ b/tests/unit/modules/default/weather/provider_utils_spec.js @@ -0,0 +1,167 @@ +const defaults = require("../../../../../js/defaults"); + +const providerUtils = require(`../../../../../${defaults.defaultModulesDir}/weather/provider-utils`); + +describe("Weather provider utils tests", () => { + describe("convertWeatherType", () => { + it("should convert OpenWeatherMap day icons correctly", () => { + expect(providerUtils.convertWeatherType("01d")).toBe("day-sunny"); + expect(providerUtils.convertWeatherType("02d")).toBe("day-cloudy"); + expect(providerUtils.convertWeatherType("10d")).toBe("rain"); + expect(providerUtils.convertWeatherType("13d")).toBe("snow"); + }); + + it("should convert OpenWeatherMap night icons correctly", () => { + expect(providerUtils.convertWeatherType("01n")).toBe("night-clear"); + expect(providerUtils.convertWeatherType("02n")).toBe("night-cloudy"); + expect(providerUtils.convertWeatherType("10n")).toBe("night-rain"); + }); + + it("should return null for unknown weather types", () => { + expect(providerUtils.convertWeatherType("99x")).toBeNull(); + expect(providerUtils.convertWeatherType("")).toBeNull(); + }); + }); + + describe("applyTimezoneOffset", () => { + it("should apply positive offset correctly", () => { + const date = new Date("2026-02-02T12:00:00Z"); + const result = providerUtils.applyTimezoneOffset(date, 120); // +2 hours + // The function converts to UTC, then applies offset + const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000 + 120 * 60000); + expect(result.getTime()).toBe(expected.getTime()); + }); + + it("should apply negative offset correctly", () => { + const date = new Date("2026-02-02T12:00:00Z"); + const result = providerUtils.applyTimezoneOffset(date, -300); // -5 hours + const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000 - 300 * 60000); + expect(result.getTime()).toBe(expected.getTime()); + }); + + it("should handle zero offset", () => { + const date = new Date("2026-02-02T12:00:00Z"); + const result = providerUtils.applyTimezoneOffset(date, 0); + const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000); + expect(result.getTime()).toBe(expected.getTime()); + }); + }); + + describe("limitDecimals", () => { + it("should truncate decimals correctly", () => { + expect(providerUtils.limitDecimals(12.3456789, 4)).toBe(12.3456); + expect(providerUtils.limitDecimals(12.3456789, 2)).toBe(12.34); + }); + + it("should handle values with fewer decimals than limit", () => { + expect(providerUtils.limitDecimals(12.34, 6)).toBe(12.34); + expect(providerUtils.limitDecimals(12, 4)).toBe(12); + }); + + it("should handle negative values", () => { + expect(providerUtils.limitDecimals(-12.3456789, 2)).toBe(-12.34); + }); + + it("should truncate not round", () => { + expect(providerUtils.limitDecimals(12.9999, 2)).toBe(12.99); + expect(providerUtils.limitDecimals(12.9999, 0)).toBe(12); + }); + }); + + describe("getSunTimes", () => { + it("should return sunrise and sunset times", () => { + const date = new Date("2026-06-21T12:00:00Z"); // Summer solstice + const lat = 52.52; // Berlin + const lon = 13.405; + + const result = providerUtils.getSunTimes(date, lat, lon); + + expect(result).toHaveProperty("sunrise"); + expect(result).toHaveProperty("sunset"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(result.sunrise.getTime()).toBeLessThan(result.sunset.getTime()); + }); + + it("should handle different locations", () => { + const date = new Date("2026-06-21T12:00:00Z"); + + // London + const london = providerUtils.getSunTimes(date, 51.5074, -0.1278); + // Tokyo + const tokyo = providerUtils.getSunTimes(date, 35.6762, 139.6503); + + expect(london.sunrise.getTime()).not.toBe(tokyo.sunrise.getTime()); + }); + }); + + describe("isDayTime", () => { + it("should return true when time is between sunrise and sunset", () => { + const sunrise = new Date("2026-02-02T07:00:00Z"); + const sunset = new Date("2026-02-02T17:00:00Z"); + const noon = new Date("2026-02-02T12:00:00Z"); + + expect(providerUtils.isDayTime(noon, sunrise, sunset)).toBe(true); + }); + + it("should return false when time is before sunrise", () => { + const sunrise = new Date("2026-02-02T07:00:00Z"); + const sunset = new Date("2026-02-02T17:00:00Z"); + const night = new Date("2026-02-02T03:00:00Z"); + + expect(providerUtils.isDayTime(night, sunrise, sunset)).toBe(false); + }); + + it("should return false when time is after sunset", () => { + const sunrise = new Date("2026-02-02T07:00:00Z"); + const sunset = new Date("2026-02-02T17:00:00Z"); + const night = new Date("2026-02-02T20:00:00Z"); + + expect(providerUtils.isDayTime(night, sunrise, sunset)).toBe(false); + }); + + it("should return true if sunrise/sunset are null", () => { + const noon = new Date("2026-02-02T12:00:00Z"); + expect(providerUtils.isDayTime(noon, null, null)).toBe(true); + }); + }); + + describe("formatTimezoneOffset", () => { + it("should format positive offsets correctly", () => { + expect(providerUtils.formatTimezoneOffset(60)).toBe("+01:00"); + expect(providerUtils.formatTimezoneOffset(120)).toBe("+02:00"); + expect(providerUtils.formatTimezoneOffset(330)).toBe("+05:30"); // India + }); + + it("should format negative offsets correctly", () => { + expect(providerUtils.formatTimezoneOffset(-300)).toBe("-05:00"); // EST + expect(providerUtils.formatTimezoneOffset(-480)).toBe("-08:00"); // PST + }); + + it("should format zero offset correctly", () => { + expect(providerUtils.formatTimezoneOffset(0)).toBe("+00:00"); + }); + + it("should pad single digits with zero", () => { + expect(providerUtils.formatTimezoneOffset(5)).toBe("+00:05"); + expect(providerUtils.formatTimezoneOffset(-5)).toBe("-00:05"); + }); + }); + + describe("getDateString", () => { + it("should format date as YYYY-MM-DD (local time)", () => { + const date = new Date(2026, 1, 2, 12, 34, 56); // Feb 2, 2026 (month is 0-indexed) + expect(providerUtils.getDateString(date)).toBe("2026-02-02"); + }); + + it("should handle single-digit months and days correctly", () => { + const date = new Date(2026, 0, 5, 12, 0, 0); // Jan 5, 2026 + expect(providerUtils.getDateString(date)).toBe("2026-01-05"); + }); + + it("should handle end of year", () => { + const date = new Date(2025, 11, 31, 23, 59, 59); // Dec 31, 2025 + expect(providerUtils.getDateString(date)).toBe("2025-12-31"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/envcanada_spec.js b/tests/unit/modules/default/weather/providers/envcanada_spec.js new file mode 100644 index 0000000000..85533c5199 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/envcanada_spec.js @@ -0,0 +1,309 @@ +/** + * Environment Canada Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Environment Canada is the Canadian weather service (XML-based). + */ + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const indexHTML = fs.readFileSync(path.join(__dirname, "../../../../../mocks/weather_envcanada_index.html"), "utf-8"); +const cityPageXML = fs.readFileSync(path.join(__dirname, "../../../../../mocks/weather_envcanada.xml"), "utf-8"); + +// Match directory listing (index) - must end with / and nothing after +const ENVCANADA_INDEX_PATTERN = /https:\/\/dd\.weather\.gc\.ca\/today\/citypage_weather\/[A-Z]{2}\/\d{2}\/$/; +// Match actual XML files +const ENVCANADA_CITYPAGE_PATTERN = /https:\/\/dd\.weather\.gc\.ca\/today\/citypage_weather\/[A-Z]{2}\/\d{2}\/.*\.xml$/; + +let server; + +beforeAll(() => { + server = setupServer( + http.get(ENVCANADA_INDEX_PATTERN, () => { + return new HttpResponse(indexHTML, { + headers: { "Content-Type": "text/html" } + }); + }), + http.get(ENVCANADA_CITYPAGE_PATTERN, () => { + return new HttpResponse(cityPageXML, { + headers: { "Content-Type": "application/xml" } + }); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("EnvCanadaProvider", () => { + let EnvCanadaProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/envcanada"); + EnvCanadaProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + expect(provider.config.siteCode).toBe("s0000458"); + expect(provider.config.provCode).toBe("ON"); + expect(provider.config.type).toBe("current"); + }); + + it("should throw error if siteCode or provCode missing", async () => { + const provider = new EnvCanadaProvider({ siteCode: "", provCode: "" }); + provider.setCallbacks(vi.fn(), vi.fn()); + await expect(provider.initialize()).rejects.toThrow("siteCode and provCode are required"); + }); + }); + + describe("Two-Step Fetch Pattern", () => { + it("should first fetch index page then city page", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + expect(result).toBeDefined(); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks( + (data) => { + resolve(data); + }, + (error) => { + reject(error); + } + ); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(-20.3); + expect(result.windSpeed).toBeCloseTo(5.28, 1); // 19 km/h -> m/s + expect(result.windFromDirection).toBe(346); // NNW + expect(result.humidity).toBe(67); + }); + + it("should use wind chill for feels like temperature when available", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // XML has windChill of -31 + expect(result.feelsLikeTemp).toBe(-31); + }); + + it("should parse sunrise/sunset from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert icon code to weather type", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Icon code 40 = "Blowing Snow" → "snow-wind" + expect(result.weatherType).toBe("snow-wind"); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("precipitationProbability"); + expect(day).toHaveProperty("weatherType"); + }); + + it("should extract max precipitation probability from day/night", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has 40% for both day and night periods + expect(result[0].precipitationProbability).toBe(40); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(24); // Real data has 24 hourly forecasts + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("precipitationProbability"); + expect(hour).toHaveProperty("weatherType"); + }); + + it("should parse EC time format correctly", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First hourly forecast is for 202602071300 = 2026-02-07 13:00 UTC + const expectedDate = new Date(Date.UTC(2026, 1, 7, 13, 0, 0)); + expect(result[0].date.getTime()).toBe(expectedDate.getTime()); + }); + }); + + describe("Error Handling", () => { + it("should handle missing city page URL", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s9999999", // Invalid site code + provCode: "ON", + type: "current" + }); + + let errorCalled = false; + provider.setCallbacks(vi.fn(), () => { + errorCalled = true; + }); + + await provider.initialize(); + provider.start(); + + // Should not call error callback if URL not found (it's expected during hour transitions) + // Wait a bit to see if callback is called + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(errorCalled).toBe(false); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/openmeteo_spec.js b/tests/unit/modules/default/weather/providers/openmeteo_spec.js new file mode 100644 index 0000000000..7201c293ae --- /dev/null +++ b/tests/unit/modules/default/weather/providers/openmeteo_spec.js @@ -0,0 +1,305 @@ +/** + * OpenMeteo Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Uses MSW to mock HTTP responses from the Open-Meteo API. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import openMeteoData from "../../../../../mocks/weather_openmeteo_current.json" with { type: "json" }; +import openMeteoCurrentWeatherData from "../../../../../mocks/weather_openmeteo_current_weather.json" with { type: "json" }; +// Real API returns current + forecast in one response +const currentData = openMeteoCurrentWeatherData; +const forecastData = openMeteoData; + +const GEOCODE_URL = "https://api.bigdatacloud.net/data/reverse-geocode-client*"; + +let server; + +beforeAll(() => { + // Mock global fetch for geocoding (used by provider's #fetchLocation) + server = setupServer( + http.get(GEOCODE_URL, () => { + return HttpResponse.json({ + city: "Munich", + locality: "Munich", + principalSubdivisionCode: "BY" + }); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("OpenMeteoProvider", () => { + let OpenMeteoProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/openmeteo"); + OpenMeteoProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.type).toBe("current"); + }); + + it("should have default values", () => { + const provider = new OpenMeteoProvider({}); + expect(provider.config.lat).toBe(0); + expect(provider.config.lon).toBe(0); + expect(provider.config.type).toBe("current"); + expect(provider.config.maxNumberOfDays).toBe(5); + expect(provider.config.apiBase).toBe("https://api.open-meteo.com/v1"); + }); + + it("should initialize without callbacks", async () => { + const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 }); + await expect(provider.initialize()).resolves.not.toThrow(); + }); + + it("should resolve location name via geocoding", async () => { + const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 }); + await provider.initialize(); + expect(provider.locationName).toBe("Munich, BY"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data correctly", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks( + (data) => { + resolve(data); + }, + (error) => { + reject(error); + } + ); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(currentData); + }) + ); + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(8.5); + expect(result.windSpeed).toBeCloseTo(4.7, 1); + expect(result.windFromDirection).toBe(9); + expect(result.humidity).toBe(83); + }); + + it("should include sunrise and sunset from daily data", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks( + (data) => { + resolve(data); + }, + (error) => { + reject(error); + } + ); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(result.minTemperature).toBe(4.7); + expect(result.maxTemperature).toBe(9.5); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data correctly", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(7); + const firstDay = result[0]; + expect(firstDay.minTemperature).toBe(-9.2); + expect(firstDay.maxTemperature).toBe(-0.2); + expect(firstDay.temperature).toBeCloseTo(-4.7, 0); // (-0.2+-9.2)/2 + + expect(firstDay.sunrise).toBeInstanceOf(Date); + expect(firstDay.sunset).toBeInstanceOf(Date); + }); + + it("should include precipitation data in forecast", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Mock data has no rain_sum field - provider returns null for missing data + expect(result[0].rain).toBeNull(); + // precipitation_sum has value 0.0 in mock data + expect(result[0].precipitationAmount).toBe(0.0); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid API response", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + expect(error).toHaveProperty("translationKey"); + }); + + it("should call error callback on network failure", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.error(); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("url"); + }); + }); + + describe("Callback Interface", () => { + it("should store callbacks via setCallbacks", () => { + const provider = new OpenMeteoProvider({}); + const onData = vi.fn(); + const onError = vi.fn(); + provider.setCallbacks(onData, onError); + expect(provider.onDataCallback).toBe(onData); + expect(provider.onErrorCallback).toBe(onError); + }); + }); + + describe("Lifecycle", () => { + it("should have start/stop methods", () => { + const provider = new OpenMeteoProvider({}); + expect(typeof provider.start).toBe("function"); + expect(typeof provider.stop).toBe("function"); + }); + + it("should clear timer on stop", async () => { + const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 }); + provider.setCallbacks(vi.fn(), vi.fn()); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.stop(); + + // Should not throw + expect(provider.fetcher).not.toBeNull(); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/openweathermap_spec.js b/tests/unit/modules/default/weather/providers/openweathermap_spec.js new file mode 100644 index 0000000000..6303978441 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/openweathermap_spec.js @@ -0,0 +1,235 @@ +/** + * OpenWeatherMap Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import onecallData from "../../../../../mocks/weather_owm_onecall.json" with { type: "json" }; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("OpenWeatherMapProvider", () => { + let OpenWeatherMapProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/openweathermap"); + OpenWeatherMapProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key" + }); + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.apiKey).toBe("test-key"); + }); + + it("should have default values", () => { + const provider = new OpenWeatherMapProvider({ apiKey: "test" }); + expect(provider.config.apiVersion).toBe("3.0"); + expect(provider.config.weatherEndpoint).toBe("/onecall"); + expect(provider.config.apiBase).toBe("https://api.openweathermap.org/data/"); + }); + }); + + describe("API Key Validation", () => { + it("should call error callback without API key", async () => { + const provider = new OpenWeatherMapProvider({ apiKey: "" }); + const onError = vi.fn(); + provider.setCallbacks(vi.fn(), onError); + await provider.initialize(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "API key is required" }) + ); + }); + + it("should not create fetcher without API key", async () => { + const provider = new OpenWeatherMapProvider({ apiKey: "" }); + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + expect(provider.fetcher).toBeNull(); + }); + + it("should throw if setCallbacks not called before initialize", async () => { + const provider = new OpenWeatherMapProvider({ apiKey: "test" }); + await expect(provider.initialize()).rejects.toThrow("setCallbacks"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse onecall current weather data", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.temperature).toBe(-0.27); + expect(result.windSpeed).toBe(3.09); + expect(result.windFromDirection).toBe(220); + expect(result.humidity).toBe(54); + expect(result.uvIndex).toBe(0); + expect(result.feelsLikeTemp).toBe(-3.9); + expect(result.weatherType).toBe("cloudy-windy"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should include precipitation data in current weather", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has no precipitation + expect(result.rain).toBeUndefined(); + expect(result.snow).toBeUndefined(); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(8); + expect(result[0].minTemperature).toBe(-11.86); + expect(result[0].maxTemperature).toBe(-0.27); + expect(result[0].snow).toBe(0.69); + expect(result[0].precipitationProbability).toBe(100); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(48); + expect(result[0].temperature).toBe(-0.66); + expect(result[0].precipitationProbability).toBe(0); + expect(result[0].rain).toBeUndefined(); + }); + }); + + describe("Timezone Handling", () => { + it("should set location name from timezone", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + await dataPromise; + + expect(provider.locationName).toBe("America/New_York"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/pirateweather_spec.js b/tests/unit/modules/default/weather/providers/pirateweather_spec.js new file mode 100644 index 0000000000..e2e3a8e103 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/pirateweather_spec.js @@ -0,0 +1,366 @@ +/** + * Pirate Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Pirate Weather is a Dark Sky API compatible service. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import pirateweatherData from "../../../../../mocks/weather_pirateweather.json" with { type: "json" }; + +const PIRATEWEATHER_URL = "https://api.pirateweather.net/forecast/*"; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("PirateweatherProvider", () => { + let PirateweatherProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/pirateweather"); + PirateweatherProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new PirateweatherProvider({ + apiKey: "test-api-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.lat).toBe(40.71); + expect(provider.config.lon).toBe(-74.0); + }); + + it("should error if API key is missing", async () => { + const provider = new PirateweatherProvider({ + lat: 40.71, + lon: -74.0 + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("API key"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(-0.26); + expect(result.feelsLikeTemp).toBe(-4.77); + expect(result.windSpeed).toBe(2.32); + expect(result.windFromDirection).toBe(166); + expect(Math.round(result.humidity)).toBe(56); // 0.56 * 100 with rounding + }); + + it("should include sunrise/sunset from daily data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert icon to weather type", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // "cloudy" icon from real data + expect(result.weatherType).toBe("cloudy"); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(8); + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + expect(day).toHaveProperty("precipitationProbability"); + }); + + it("should convert precipitation accumulation from cm to mm", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First day has precipAccumulation: 0.0 cm + expect(result[0].precipitationAmount).toBe(0); + }); + + it("should categorize precipitation by type", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First day has precipType: "snow" + expect(result[0].rain).toBe(0); + expect(result[0].snow).toBe(0); + + // Second day has precipType: "snow" with 0.0 accumulation + expect(result[1].rain).toBe(0); + expect(result[1].snow).toBe(0); + }); + + it("should convert precipitation probability to percentage", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // 0.33 -> 33% + expect(result[0].precipitationProbability).toBe(33); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(48); + + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("feelsLikeTemp"); + expect(hour).toHaveProperty("windSpeed"); + expect(hour).toHaveProperty("weatherType"); + }); + + it("should handle hourly precipitation", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First hour has 0.0 cm precipitation + expect(result[0].precipitationAmount).toBe(0); + expect(result[0].rain).toBe(0); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid JSON response", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error.message).toContain("No usable data"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/smhi_spec.js b/tests/unit/modules/default/weather/providers/smhi_spec.js new file mode 100644 index 0000000000..b7e1211785 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/smhi_spec.js @@ -0,0 +1,208 @@ +/** + * SMHI Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * SMHI provides data only for Sweden, uses metric system. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import smhiData from "../../../../../mocks/weather_smhi.json" with { type: "json" }; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("SMHIProvider", () => { + let SMHIProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/smhi"); + SMHIProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686 + }); + expect(provider.config.lat).toBe(59.3293); + expect(provider.config.lon).toBe(18.0686); + expect(provider.config.precipitationValue).toBe("pmedian"); + }); + + it("should fallback to pmedian for invalid precipitationValue", () => { + const provider = new SMHIProvider({ + precipitationValue: "invalid" + }); + expect(provider.config.precipitationValue).toBe("pmedian"); + }); + + it("should accept valid precipitationValue options", () => { + for (const value of ["pmin", "pmean", "pmedian", "pmax"]) { + const provider = new SMHIProvider({ precipitationValue: value }); + expect(provider.config.precipitationValue).toBe(value); + } + }); + }); + + describe("Coordinate Validation", () => { + it("should limit coordinates to 6 decimal places", async () => { + const provider = new SMHIProvider({ + lat: 59.32930123456789, + lon: 18.06860123456789 + }); + provider.setCallbacks(vi.fn(), vi.fn()); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(smhiData); + }) + ); + + await provider.initialize(); + + // After validateCoordinates(config, 6), decimals should be truncated + expect(provider.config.lat.toString().split(".")[1]?.length).toBeLessThanOrEqual(6); + expect(provider.config.lon.toString().split(".")[1]?.length).toBeLessThanOrEqual(6); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from timeSeries", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(smhiData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(typeof result.temperature).toBe("number"); + expect(typeof result.windSpeed).toBe("number"); + expect(typeof result.humidity).toBe("number"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should detect precipitation category correctly", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + // Use data with rain (pcat=3 at index 2) + const rainData = JSON.parse(JSON.stringify(smhiData)); + // Make the rain entry the closest to "now" + rainData.timeSeries = [rainData.timeSeries[2]]; + rainData.timeSeries[0].validTime = new Date().toISOString(); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(rainData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.rain).toBe(0.0); // pmedian value with pcat=3 (rain) + expect(result.precipitationAmount).toBe(0.0); + expect(result.snow).toBe(0); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(smhiData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const firstDay = result[0]; + expect(firstDay).toHaveProperty("date"); + expect(firstDay).toHaveProperty("minTemperature"); + expect(firstDay).toHaveProperty("maxTemperature"); + expect(firstDay.minTemperature).toBeLessThanOrEqual(firstDay.maxTemperature); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid data", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json({ invalid: true }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/ukmetofficedatahub_spec.js b/tests/unit/modules/default/weather/providers/ukmetofficedatahub_spec.js new file mode 100644 index 0000000000..a839a7aadc --- /dev/null +++ b/tests/unit/modules/default/weather/providers/ukmetofficedatahub_spec.js @@ -0,0 +1,325 @@ +/** + * UK Met Office DataHub Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import hourlyData from "../../../../../mocks/weather_ukmetoffice.json" with { type: "json" }; +import dailyData from "../../../../../mocks/weather_ukmetoffice_daily.json" with { type: "json" }; + +const UKMETOFFICE_HOURLY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly*"; +const UKMETOFFICE_THREE_HOURLY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/three-hourly*"; +const UKMETOFFICE_DAILY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily*"; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("UKMetOfficeDataHubProvider", () => { + let UKMetOfficeDataHubProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/ukmetofficedatahub"); + UKMetOfficeDataHubProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-api-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.lat).toBe(51.5); + expect(provider.config.lon).toBe(-0.12); + }); + + it("should error if API key is missing", async () => { + const provider = new UKMetOfficeDataHubProvider({ + lat: 51.5, + lon: -0.12 + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("API key"); + }); + }); + + describe("Forecast Type Mapping", () => { + it("should use hourly endpoint for current type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + let requestedUrl = null; + server.use( + http.get(UKMETOFFICE_HOURLY_URL, ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json(hourlyData); + }) + ); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + provider.start(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(requestedUrl).toContain("/hourly?"); + }); + + it("should use daily endpoint for forecast type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "forecast" + }); + + let requestedUrl = null; + server.use( + http.get(UKMETOFFICE_DAILY_URL, ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json(dailyData); + }) + ); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + provider.start(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(requestedUrl).toContain("/daily?"); + }); + + it("should use three-hourly endpoint for hourly type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "hourly" + }); + + let requestedUrl = null; + server.use( + http.get(UKMETOFFICE_THREE_HOURLY_URL, ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json(hourlyData); + }) + ); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + provider.start(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(requestedUrl).toContain("/three-hourly?"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from hourly data", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBeDefined(); + expect(result.windSpeed).toBeDefined(); + expect(result.humidity).toBeDefined(); + expect(result.weatherType).not.toBeNull(); + }); + + it("should include sunrise/sunset from SunCalc", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert weather code to weather type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.weatherType).toBeTruthy(); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_DAILY_URL, () => { + return HttpResponse.json(dailyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_THREE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("windSpeed"); + expect(hour).toHaveProperty("weatherType"); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid response", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/weatherbit_spec.js b/tests/unit/modules/default/weather/providers/weatherbit_spec.js new file mode 100644 index 0000000000..8bea45f141 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/weatherbit_spec.js @@ -0,0 +1,247 @@ +/** + * Weatherbit Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import currentData from "../../../../../mocks/weather_weatherbit.json" with { type: "json" }; +import forecastData from "../../../../../mocks/weather_weatherbit_forecast.json" with { type: "json" }; +import hourlyData from "../../../../../mocks/weather_weatherbit_hourly.json" with { type: "json" }; + +const WEATHERBIT_CURRENT_URL = "https://api.weatherbit.io/v2.0/current*"; +const WEATHERBIT_FORECAST_URL = "https://api.weatherbit.io/v2.0/forecast/daily*"; +const WEATHERBIT_HOURLY_URL = "https://api.weatherbit.io/v2.0/forecast/hourly*"; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("WeatherbitProvider", () => { + let WeatherbitProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/weatherbit"); + WeatherbitProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new WeatherbitProvider({ + apiKey: "test-api-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.lat).toBe(40.71); + expect(provider.config.lon).toBe(-74.0); + }); + + it("should error if API key is missing", async () => { + const provider = new WeatherbitProvider({ + lat: 40.71, + lon: -74.0 + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("API key"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(1); + expect(result.windSpeed).toBe(1.5); + expect(result.windFromDirection).toBe(210); + expect(result.humidity).toBe(47); + }); + + it("should parse sunrise/sunset from HH:mm format", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert icon code to weather type", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.weatherType).not.toBeNull(); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_FORECAST_URL, () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + expect(day).toHaveProperty("precipitationProbability"); + }); + }); + + describe("Hourly Parsing", () => { + it("should handle hourly API endpoint access error", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERBIT_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + + expect(error).toBeDefined(); + expect(error.message || error).toContain("No usable data"); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid response", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json({ data: [] }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/weatherflow_spec.js b/tests/unit/modules/default/weather/providers/weatherflow_spec.js new file mode 100644 index 0000000000..2eb2fdb4a4 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/weatherflow_spec.js @@ -0,0 +1,264 @@ +/** + * WeatherFlow Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import weatherflowData from "../../../../../mocks/weather_weatherflow.json" with { type: "json" }; + +const WEATHERFLOW_URL = "https://swd.weatherflow.com/swd/rest/better_forecast*"; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("WeatherFlowProvider", () => { + let WeatherFlowProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/weatherflow"); + WeatherFlowProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + expect(provider.config.token).toBe("test-token"); + expect(provider.config.stationid).toBe("12345"); + }); + + it("should error if token or stationid is missing", async () => { + const provider = new WeatherFlowProvider({}); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("token"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(16); + expect(result.humidity).toBe(28); + expect(result.weatherType).not.toBeNull(); + }); + + it("should convert wind speed from km/h to m/s", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Wind speed 15 km/h -> ~4.17 m/s + expect(result.windSpeed).toBeCloseTo(4.17, 1); + }); + + it("should include sunrise/sunset", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("windSpeed"); + }); + + it("should aggregate UV data from hourly forecasts", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First day should have UV from hourly data + expect(result[0]).toHaveProperty("uvIndex"); + expect(result[0].uvIndex).toBeGreaterThanOrEqual(0); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid response", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + // Invalid responses return null without calling error callback + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/weathergov_spec.js b/tests/unit/modules/default/weather/providers/weathergov_spec.js new file mode 100644 index 0000000000..81a7edd94f --- /dev/null +++ b/tests/unit/modules/default/weather/providers/weathergov_spec.js @@ -0,0 +1,412 @@ +/** + * Weather.gov Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Weather.gov is the US National Weather Service API. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import pointsData from "../../../../../mocks/weather_weathergov_points.json" with { type: "json" }; +import stationsData from "../../../../../mocks/weather_weathergov_stations.json" with { type: "json" }; +import currentData from "../../../../../mocks/weather_weathergov_current.json" with { type: "json" }; +import forecastData from "../../../../../mocks/weather_weathergov_forecast.json" with { type: "json" }; +import hourlyData from "../../../../../mocks/weather_weathergov_hourly.json" with { type: "json" }; + +const WEATHERGOV_POINTS_URL = "https://api.weather.gov/points/*"; +const WEATHERGOV_STATIONS_URL = "https://api.weather.gov/gridpoints/*/stations"; +const WEATHERGOV_CURRENT_URL = "https://api.weather.gov/stations/*/observations/latest"; +const WEATHERGOV_FORECAST_URL = "https://api.weather.gov/gridpoints/*/forecast*"; +const WEATHERGOV_HOURLY_URL = "https://api.weather.gov/gridpoints/*/forecast/hourly*"; + +let server; + +beforeAll(() => { + server = setupServer( + // Default handlers for initialization + http.get(WEATHERGOV_POINTS_URL, () => { + return HttpResponse.json(pointsData); + }), + http.get(WEATHERGOV_STATIONS_URL, () => { + return HttpResponse.json(stationsData); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); + // Re-add default initialization handlers + server.use( + http.get(WEATHERGOV_POINTS_URL, () => { + return HttpResponse.json(pointsData); + }), + http.get(WEATHERGOV_STATIONS_URL, () => { + return HttpResponse.json(stationsData); + }) + ); +}); + +describe("WeatherGovProvider", () => { + let WeatherGovProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/weathergov"); + WeatherGovProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + expect(provider.config.lat).toBe(40.71); + expect(provider.config.lon).toBe(-74.0); + expect(provider.config.type).toBe("current"); + }); + + it("should have default update interval", () => { + const provider = new WeatherGovProvider({}); + expect(provider.config.updateInterval).toBe(600000); // 10 minutes + }); + }); + + describe("Two-Step Initialization", () => { + it("should fetch points URL and then stations URL", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + provider.setCallbacks(vi.fn(), vi.fn()); + + let pointsRequested = false; + let stationsRequested = false; + + server.use( + http.get(WEATHERGOV_POINTS_URL, () => { + pointsRequested = true; + return HttpResponse.json(pointsData); + }), + http.get(WEATHERGOV_STATIONS_URL, () => { + stationsRequested = true; + return HttpResponse.json(stationsData); + }) + ); + + await provider.initialize(); + + expect(pointsRequested).toBe(true); + expect(stationsRequested).toBe(true); + expect(provider.locationName).toBe("Washington, DC"); + }); + + it("should store forecast URLs after initialization", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0 + }); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + + expect(provider.forecastURL).toContain("forecast?units=si"); + expect(provider.forecastHourlyURL).toContain("forecast/hourly?units=si"); + expect(provider.stationObsURL).toContain("observations/latest"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(-1); + expect(result.windSpeed).toBe(0); + expect(result.windFromDirection).toBe(0); + expect(result.humidity).toBe(64); // Rounded from 63.77 + expect(result.weatherType).not.toBeNull(); + }); + + it("should use heat index or wind chill for feels like temperature", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has null windChill - falls back to temperature + expect(result.feelsLikeTemp).toBe(-1); + }); + + it("should include sunrise/sunset", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_FORECAST_URL, () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + expect(day).toHaveProperty("precipitationProbability"); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(156); // Real API returns 156 hourly periods + expect(result[0]).toHaveProperty("temperature"); + expect(result[0]).toHaveProperty("windSpeed"); + expect(result[0]).toHaveProperty("windFromDirection"); + expect(result[0]).toHaveProperty("weatherType"); + }); + + it("should convert wind direction strings to degrees", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has "S" wind for both hours + expect(result[0].windFromDirection).toBe(180); + // Third hour also has "S" wind + expect(result[2].windFromDirection).toBe(180); + }); + + it("should parse wind speed with units", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Wind speeds should be converted from km/h to m/s + expect(result[0].windSpeed).toBeCloseTo(1.11, 1); // Real data: 4 km/h -> ~1.11 m/s + }); + }); + + describe("Error Handling", () => { + it("should categorize DNS errors as retryable", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERGOV_POINTS_URL, () => { + return HttpResponse.error(); + }) + ); + + await provider.initialize(); + + // Should call error callback + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + + it("should handle invalid JSON response", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json({ properties: null }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error.message).toContain("Invalid"); + }); + }); + + describe("Weather Type Conversion", () => { + it("should convert textDescription to weather types", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + const testData = JSON.parse(JSON.stringify(currentData)); + testData.properties.textDescription = "Thunderstorm"; + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(testData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Thunderstorm should map to day or night thunderstorm + expect(["thunderstorm", "night-thunderstorm"]).toContain(result.weatherType); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/yr_spec.js b/tests/unit/modules/default/weather/providers/yr_spec.js new file mode 100644 index 0000000000..4602a8d840 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/yr_spec.js @@ -0,0 +1,287 @@ +/** + * Yr.no Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Yr.no is the Norwegian Meteorological Institute API. + * + * Uses fake timers to ensure deterministic timeseries selection. + * The provider picks the closest past entry from timeseries based on new Date(). + * Fixed to 2026-02-06T21:30:00Z → selects timeseries[0] at 21:00 with T=-5.8°C. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; + +import yrData from "../../../../../mocks/weather_yr.json" with { type: "json" }; + +const YR_FORECAST_URL = "https://api.met.no/weatherapi/locationforecast/**"; +const YR_SUNRISE_URL = "https://api.met.no/weatherapi/sunrise/**"; + +// Fixed time: 30 minutes after the first timeseries entry (2026-02-06T21:00:00Z) +// This ensures timeseries[0] is always chosen as the closest past entry. +const FAKE_NOW = new Date("2026-02-06T21:30:00Z"); + +let server; + +beforeAll(() => { + server = setupServer( + http.get(({ request }) => request.url.includes("/locationforecast/"), () => { + return HttpResponse.json(yrData); + }), + http.get(({ request }) => request.url.includes("/sunrise/"), () => { + return HttpResponse.json({ + when: { interval: ["2026-02-06T00:00:00+01:00"] }, + properties: { + sunrise: { time: "2026-02-06T08:30:00+01:00" }, + sunset: { time: "2026-02-06T16:30:00+01:00" } + } + }); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +beforeEach(() => { + vi.useFakeTimers({ now: FAKE_NOW }); +}); + +afterEach(() => { + vi.useRealTimers(); + server.resetHandlers(); +}); + +describe("YrProvider", () => { + let YrProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/yr"); + YrProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + altitude: 94 + }); + expect(provider.config.lat).toBe(59.91); + expect(provider.config.lon).toBe(10.72); + expect(provider.config.altitude).toBe(94); + }); + + it("should enforce minimum 10-minute update interval", () => { + const provider = new YrProvider({ + updateInterval: 60000 // 1 minute - too short + }); + expect(provider.config.updateInterval).toBe(600000); + }); + + it("should allow intervals >= 10 minutes", () => { + const provider = new YrProvider({ + updateInterval: 900000 // 15 minutes + }); + expect(provider.config.updateInterval).toBe(900000); + }); + }); + + describe("Coordinate Validation", () => { + it("should limit coordinates to 4 decimal places", async () => { + const provider = new YrProvider({ + lat: 59.91234567, + lon: 10.72345678 + }); + provider.setCallbacks(vi.fn(), vi.fn()); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + + expect(provider.config.lat.toString().split(".")[1]?.length).toBeLessThanOrEqual(4); + expect(provider.config.lon.toString().split(".")[1]?.length).toBeLessThanOrEqual(4); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from timeseries", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + // With fake time at 21:30, provider selects timeseries[0] (21:00 UTC) + expect(result.temperature).toBe(-5.8); + expect(result.windSpeed).toBe(6.0); + expect(result.windFromDirection).toBe(37.0); + expect(result.humidity).toBe(66.5); + // 21:00 is after sunset (16:30), symbol_code "snow" maps to "snow" + expect(result.weatherType).toBe("snow"); + }); + + it("should include sunrise/sunset from stellar data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(result.sunset.getTime()).toBeGreaterThan(result.sunrise.getTime()); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day.minTemperature).toBeLessThanOrEqual(day.maxTemperature); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const hour = result[0]; + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("windSpeed"); + expect(hour).toHaveProperty("precipitationAmount"); + expect(hour).toHaveProperty("weatherType"); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json({ properties: {} }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); + + describe("Weather Type Conversion", () => { + it("should convert yr symbol codes correctly", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current", + currentForecastHours: 1 + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + // Uses yrData from beforeAll which has symbol_code "snow" + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // 21:00 is after sunset (16:30), next_1_hours symbol_code is "snow" + expect(result.weatherType).toBe("snow"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/weather_providers_spec.js b/tests/unit/modules/default/weather/weather_providers_spec.js new file mode 100644 index 0000000000..4d226da202 --- /dev/null +++ b/tests/unit/modules/default/weather/weather_providers_spec.js @@ -0,0 +1,189 @@ +/** + * Weather Provider Smoke Tests + * + * Tests basic provider functionality: configuration, callbacks, and validation. + * Parser logic with private methods (#) is validated through live testing. + */ +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from "vitest"; + +// Mock global fetch for location lookup +const originalFetch = global.fetch; + +global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ city: "Munich", locality: "Munich" }) +})); + +// Restore original fetch after all tests +afterAll(() => { + global.fetch = originalFetch; +}); + +describe("Weather Provider Smoke Tests", () => { + describe("OpenMeteoProvider", () => { + let OpenMeteoProvider; + let provider; + + beforeAll(async () => { + const module = await import("../../../../../defaultmodules/weather/providers/openmeteo"); + OpenMeteoProvider = module.default; + }); + + beforeEach(() => { + provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current", + updateInterval: 600000 + }); + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.type).toBe("current"); + expect(provider.config.updateInterval).toBe(600000); + }); + + it("should have default values", () => { + const defaultProvider = new OpenMeteoProvider({}); + expect(defaultProvider.config.lat).toBe(0); + expect(defaultProvider.config.lon).toBe(0); + expect(defaultProvider.config.type).toBe("current"); + expect(defaultProvider.config.maxNumberOfDays).toBe(5); + }); + + it("should accept all supported types", () => { + expect(new OpenMeteoProvider({ type: "current" }).config.type).toBe("current"); + expect(new OpenMeteoProvider({ type: "forecast" }).config.type).toBe("forecast"); + expect(new OpenMeteoProvider({ type: "hourly" }).config.type).toBe("hourly"); + }); + }); + + describe("Callback Interface", () => { + it("should store callbacks via setCallbacks", () => { + const onData = vi.fn(); + const onError = vi.fn(); + provider.setCallbacks(onData, onError); + expect(provider.onDataCallback).toBe(onData); + expect(provider.onErrorCallback).toBe(onError); + }); + + it("should initialize without callbacks", async () => { + await expect(provider.initialize()).resolves.not.toThrow(); + }); + }); + + describe("Public Methods", () => { + it("should have start/stop methods", () => { + expect(typeof provider.start).toBe("function"); + expect(typeof provider.stop).toBe("function"); + }); + + it("should have initialize method", () => { + expect(typeof provider.initialize).toBe("function"); + }); + + it("should have setCallbacks method", () => { + expect(typeof provider.setCallbacks).toBe("function"); + }); + }); + }); + + describe("OpenWeatherMapProvider", () => { + let OpenWeatherMapProvider; + let provider; + + beforeAll(async () => { + const module = await import("../../../../../defaultmodules/weather/providers/openweathermap"); + OpenWeatherMapProvider = module.default; + }); + + beforeEach(() => { + provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-api-key", + type: "current" + }); + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.type).toBe("current"); + }); + + it("should have default values", () => { + const defaultProvider = new OpenWeatherMapProvider({ apiKey: "test" }); + expect(defaultProvider.config.apiVersion).toBe("3.0"); + expect(defaultProvider.config.weatherEndpoint).toBe("/onecall"); + expect(defaultProvider.config.apiBase).toBe("https://api.openweathermap.org/data/"); + }); + + it("should accept all supported types", () => { + expect(new OpenWeatherMapProvider({ apiKey: "test", type: "current" }).config.type).toBe("current"); + expect(new OpenWeatherMapProvider({ apiKey: "test", type: "forecast" }).config.type).toBe("forecast"); + expect(new OpenWeatherMapProvider({ apiKey: "test", type: "hourly" }).config.type).toBe("hourly"); + }); + }); + + describe("API Key Validation", () => { + it("should call onErrorCallback if no API key provided", async () => { + const noKeyProvider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "" + }); + + const onError = vi.fn(); + noKeyProvider.setCallbacks(vi.fn(), onError); + await noKeyProvider.initialize(); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: "API key is required" + }) + ); + }); + + it("should not create fetcher without API key", async () => { + const noKeyProvider = new OpenWeatherMapProvider({ + apiKey: "" + }); + noKeyProvider.setCallbacks(vi.fn(), vi.fn()); + await noKeyProvider.initialize(); + + expect(noKeyProvider.fetcher).toBeNull(); + }); + }); + + describe("Callback Interface", () => { + it("should store callbacks via setCallbacks", () => { + const onData = vi.fn(); + const onError = vi.fn(); + provider.setCallbacks(onData, onError); + expect(provider.onDataCallback).toBe(onData); + expect(provider.onErrorCallback).toBe(onError); + }); + }); + + describe("Public Methods", () => { + it("should have start/stop methods", () => { + expect(typeof provider.start).toBe("function"); + expect(typeof provider.stop).toBe("function"); + }); + + it("should have initialize method", () => { + expect(typeof provider.initialize).toBe("function"); + }); + + it("should have setCallbacks method", () => { + expect(typeof provider.setCallbacks).toBe("function"); + }); + }); + }); +}); diff --git a/tests/utils/weather_mocker.js b/tests/utils/weather_mocker.js deleted file mode 100644 index c0ebbba1c4..0000000000 --- a/tests/utils/weather_mocker.js +++ /dev/null @@ -1,52 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const exec = require("node:child_process").execSync; - -/** - * @param {string} type what data to read, can be "current" "forecast" or "hourly - * @param {object} extendedData extra data to add to the default mock data - * @returns {string} mocked current weather data - */ -const readMockData = (type, extendedData = {}) => { - let fileName; - - switch (type) { - case "forecast": - fileName = "weather_forecast.json"; - break; - case "hourly": - fileName = "weather_hourly.json"; - break; - case "current": - default: - fileName = "weather_current.json"; - break; - } - - const fileData = JSON.parse(fs.readFileSync(path.resolve(`${__dirname}/../mocks/${fileName}`)).toString()); - const mergedData = JSON.stringify({ ...{}, ...fileData, ...extendedData }); - return mergedData; -}; - -const injectMockData = (configFileName, extendedData = {}) => { - let mockWeather; - if (configFileName.includes("forecast")) { - mockWeather = readMockData("forecast", extendedData); - } else if (configFileName.includes("hourly")) { - mockWeather = readMockData("hourly", extendedData); - } else { - mockWeather = readMockData("current", extendedData); - } - let content = fs.readFileSync(configFileName).toString(); - content = content.replace("#####WEATHERDATA#####", mockWeather); - const tempFile = configFileName.replace(".js", "_temp.js"); - fs.writeFileSync(tempFile, content); - return tempFile; -}; - -const cleanupMockData = () => { - const tempDir = path.resolve(`${__dirname}/../configs`).toString(); - exec(`find ${tempDir} -type f -name *_temp.js -delete`); -}; - -module.exports = { injectMockData, cleanupMockData };