diff --git a/.gitignore b/.gitignore index 251ce6d..2881f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ dist-ssr *.njsproj *.sln *.sw? + + +# Build-time secrets (never commit) +.env +.env.* +!.env.example \ No newline at end of file diff --git a/src/components/SettingsDialog.svelte b/src/components/SettingsDialog.svelte index c5e36db..9e20cfa 100644 --- a/src/components/SettingsDialog.svelte +++ b/src/components/SettingsDialog.svelte @@ -61,6 +61,24 @@
+ +
+

Data source

+
+
+ Weather provider + NWS is US-only; falls back to Open-Meteo if unavailable. +
+ +
+
+

Units

@@ -267,6 +285,18 @@ border-top: 1px solid var(--color-border); } + .setting-col { + display: flex; + flex-direction: column; + gap: 2px; + padding-right: 12px; + } + + .setting-help { + font-size: 0.72rem; + color: var(--color-text-muted); + } + .setting-row span { color: var(--color-text); } diff --git a/src/components/WeatherDetail.svelte b/src/components/WeatherDetail.svelte index 266a3e3..d243f34 100644 --- a/src/components/WeatherDetail.svelte +++ b/src/components/WeatherDetail.svelte @@ -174,6 +174,10 @@
+
+ Data source + {data?._source === 'nws' ? 'NWS (National Weather Service)' : 'Open-Meteo'} +
Local time {data.timezone || '--'} diff --git a/src/lib/api/nws.js b/src/lib/api/nws.js new file mode 100644 index 0000000..02d1db1 --- /dev/null +++ b/src/lib/api/nws.js @@ -0,0 +1,235 @@ +/** + * NWS API (api.weather.gov) client + normalizer. + * + * Converts the National Weather Service forecast into the same flat shape the + * app's components expect (an Open-Meteo-style response), so the rest of the + * UI needs no per-source branches. US-only: NWS is queried via + * /points/{lat},{lon} which resolves to the nearest gridpoint forecast. + */ + +import { NWS_USER_AGENT } from '../config.js' + +const API = 'https://api.weather.gov' + + +async function jsonGet(url) { + if (!url) throw new Error('Missing NWS forecast URL') + const res = await fetch(url, { + headers: { + 'User-Agent': NWS_USER_AGENT, + 'Accept': 'application/geo+json', + }, + }) + if (!res.ok) throw new Error(`NWS API error: ${res.status} ${res.statusText}`) + return res.json() +} + +// --- pure helpers (also unit-tested) --- + +function ftoC(f) { + return (f - 32) * 5 / 9 +} +function mphToKmh(mph) { + return mph * 1.609344 +} + +/** Normalize a value to the requested unit. */ +function temp(v, units) { + return units === 'imperial' ? v : ftoC(v) +} +function speed(v, units) { + return units === 'imperial' ? v : mphToKmh(v) +} + +const CARD = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'] +export function compassToDeg(dir) { + if (dir == null) return null + const s = String(dir).trim().toUpperCase() + const i = CARD.indexOf(s) + return i >= 0 ? i * 22.5 : null +} + +/** Extract the max wind speed (mph) from strings like "5 to 10 mph" or "12 mph". */ +export function parseWind(s) { + if (s == null) return null + const nums = String(s).match(/\d+(\.\d+)?/g) + if (!nums || !nums.length) return null + return Math.max(...nums.map(Number)) +} + +/** Map an NWS shortForecast string to a WMO code for icons/severity. */ +export function shortForecastToWmo(text) { + const t = (text || '').toLowerCase() + if (/thunderstorm|tstm|severe thunder/.test(t)) return /\bsevere\b/.test(t) ? 96 : 95 + if (/freez|x-ice|ice pellets/.test(t) && /rain|drizzle|shower/.test(t)) return 66 + if (/snow|blizzard|flurr|sleet|snow shower/.test(t)) return /heavy|blizzard/.test(t) ? 75 : 71 + if (/freez|ice/.test(t)) return 66 + if (/rain|shower|drizzle|sprinkl/.test(t)) return /heavy|strong|torrential/.test(t) ? 65 : /slight|isolated|scattered/.test(t) ? 61 : 63 + if (/fog|haze|smoke|brume/.test(t)) return 45 + if (/partly (cloudy|sunny)/.test(t)) return 2 + if (/overcast|covering|cloudy/.test(t)) return 3 + if (/sunny|clear|fair/.test(t)) return /most|mostly/.test(t) ? 2 : 0 + return 1 +} + +function wmoRank(code) { + if (code >= 95) return 8 + if (code >= 80 && code <= 86) return 7 + if (code >= 65 && code <= 82) return 6 + if (code >= 71 && code <= 77) return 5 + if (code >= 45 && code <= 48) return 4 + if (code <= 3) return 1 + return 2 +} + +/** Local calendar date "YYYY-MM-DD" for an ISO timestamp in a timezone. */ +export function dateKey(iso, tz) { + const p = new Intl.DateTimeFormat('en-CA', { + timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', + }).formatToParts(new Date(iso)) + const P = Object.fromEntries(p.map((x) => [x.type, x.value])) + return `${P.year}-${P.month}-${P.day}` +} + +/** Local naive wall-clock "YYYY-MM-DDTHH:MM" (no offset) for an ISO timestamp. */ +export function localTimeString(iso, tz) { + const p = new Intl.DateTimeFormat('en-CA', { + timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }).formatToParts(new Date(iso)) + const P = Object.fromEntries(p.map((x) => [x.type, x.value])) + const h = P.hour === '24' ? '00' : String(P.hour).padStart(2, '0') + return `${P.year}-${P.month}-${P.day}T${h}:${String(P.minute).padStart(2, '0')}` +} + +function prob(period) { + const v = period?.probabilityOfPrecipitation?.value + return v == null ? 0 : v +} + +/** Build daily arrays from NWS forecast periods (14 periods ~ 7 days). */ +function buildDaily(periods, tz, units) { + const byDate = new Map() + for (const pd of periods) { + const key = dateKey(pd.startTime, tz) + if (!byDate.has(key)) { + byDate.set(key, { highs: [], lows: [], precip: [], codes: [], winds: [] }) + } + const d = byDate.get(key) + const t = pd.temperature + if (t != null) { + const c = temp(t, units) + if (pd.isDaytime) d.highs.push(c) + else d.lows.push(c) + } + d.precip.push(prob(pd)) + d.codes.push(shortForecastToWmo(pd.shortForecast)) + const ws = parseWind(pd.windSpeed) + if (ws != null) d.winds.push(ws) + } + + const dates = [...byDate.keys()].sort() + const time = [], temperature_2m_max = [], temperature_2m_min = [] + const precipitation_probability_max = [], weather_code = [], wind_speed_10m_max = [] + for (const key of dates) { + const d = byDate.get(key) + const high = d.highs.length ? Math.max(...d.highs) : (d.lows.length ? Math.max(...d.lows) : null) + const low = d.lows.length ? Math.min(...d.lows) : (d.highs.length ? Math.min(...d.highs) : null) + time.push(key) + temperature_2m_max.push(high) + temperature_2m_min.push(low) + precipitation_probability_max.push(Math.max(...d.precip, 0)) + weather_code.push(d.codes.reduce((a, b) => (wmoRank(b) > wmoRank(a) ? b : a))) + wind_speed_10m_max.push(speed(Math.max(...d.winds, 0), units)) + } + return { time, temperature_2m_max, temperature_2m_min, precipitation_probability_max, weather_code, wind_speed_10m_max } +} + +/** + * Normalize raw NWS responses into the app's shared shape. + * @param {object} src - { points, forecast, hourly, grid, timeZone, units } + */ +export function normalizeNWS(src) { + const { points, forecast, hourly, grid, timeZone, units } = src + const tz = timeZone || 'UTC' + const hourlyPeriods = hourly?.properties?.periods || [] + const forecastPeriods = forecast?.properties?.periods || [] + + // Current = first hourly period (most recent). + const cur = hourlyPeriods[0] + let current = null + if (cur) { + const ws = parseWind(cur.windSpeed) + const apparent = cur.apparentTemperature != null ? temp(cur.apparentTemperature, units) : temp(cur.temperature, units) + current = { + time: localTimeString(cur.startTime, tz), + temperature_2m: temp(cur.temperature, units), + apparent_temperature: apparent, + relative_humidity_2m: cur.relativeHumidity?.value ?? null, + weather_code: shortForecastToWmo(cur.shortForecast), + wind_speed_10m: ws != null ? speed(ws, units) : null, + wind_direction_10m: compassToDeg(cur.windDirection), + wind_gusts_10m: null, + pressure_msl: null, + uv_index: null, + is_day: cur.isDaytime ? 1 : 0, + } + } + + // ---- hourly (all returned periods; the UI slices the next 24) ---- + const time = [], temperature_2m = [], precipitation_probability = [], weather_code = [], wind_speed_10m = [] + for (const pd of hourlyPeriods) { + time.push(localTimeString(pd.startTime, tz)) + temperature_2m.push(temp(pd.temperature, units)) + precipitation_probability.push(prob(pd)) + weather_code.push(shortForecastToWmo(pd.shortForecast)) + const ws = parseWind(pd.windSpeed) + wind_speed_10m.push(ws != null ? speed(ws, units) : null) + } + + // ---- daily ---- + const daily = buildDaily(forecastPeriods, tz, units) + + // ---- sunrise / sunset (today only, from gridpoints buildSet) ---- + const rise = grid?.properties?.riseSet + if (rise) { + const sr = rise.sunrise?.[0]?.time + const ss = rise.sunset?.[0]?.time + if (sr) daily.sunrise = [localTimeString(sr, tz)] + if (ss) daily.sunset = [localTimeString(ss, tz)] + } + + const elevation = grid?.properties?.elevation?.value + + return { + _source: 'nws', + timezone: tz, + utc_offset_seconds: null, + elevation, + current, + hourly: { time, temperature_2m, precipitation_probability, weather_code, wind_speed_10m }, + daily, + } +} + +/** + * Fetch + normalize the NWS forecast for a latitude/longitude. + * US-only; throws for non-US points (NWS returns 404 outside its domain). + * @param {number} lat + * @param {number} lon + * @param {string} [units='imperial'] - 'imperial' or 'metric' + */ +export async function fetchForecastNWS(lat, lon, units = 'imperial') { + const pointsUrl = `${API}/points/${lat},${lon}` + const points = await jsonGet(pointsUrl) + const props = points.properties || {} + const tz = props.timeZone || 'America/New_York' + + const [forecast, hourly, grid] = await Promise.all([ + jsonGet(props.forecast), + jsonGet(props.forecastHourly), + jsonGet(props.forecastGridData), + ]) + + return normalizeNWS({ points, forecast, hourly, grid, timeZone: tz, units }) +} diff --git a/src/lib/config.js b/src/lib/config.js new file mode 100644 index 0000000..96ca8b2 --- /dev/null +++ b/src/lib/config.js @@ -0,0 +1,19 @@ +/** + * Build-time configuration. + * + * Values here are read from Vite environment variables (`import.meta.env`), + * inlined at build time - so the deployed bundle carries whatever is set in + * the local env file when you run `npm run build`. Nothing secret is committed + * to the repo; set it in a local (gitignored) .env.local: + * + * VITE_NWS_EMAIL=you@example.com + * + * NWS (api.weather.gov) requires a descriptive User-Agent with a contact + * address. If not configured we still send an app identifier, but a real + * contact email is recommended for stable access. + */ +const NWS_EMAIL = import.meta.env.VITE_NWS_EMAIL || '' + +export const NWS_USER_AGENT = NWS_EMAIL + ? `WeatherLens/0.1 (${NWS_EMAIL})` + : 'WeatherLens/0.1 (weather app; contact not configured)' diff --git a/src/lib/storage/db.js b/src/lib/storage/db.js index ca93dab..9de0ec5 100644 --- a/src/lib/storage/db.js +++ b/src/lib/storage/db.js @@ -123,6 +123,7 @@ export async function clearAllLocations() { const DEFAULT_SETTINGS = { units: 'metric', + source: 'open-meteo', alertsEnabled: true, alertThresholds: { precip: 70, diff --git a/src/lib/stores/app.svelte.js b/src/lib/stores/app.svelte.js index 4d8e0cf..9ff75b9 100644 --- a/src/lib/stores/app.svelte.js +++ b/src/lib/stores/app.svelte.js @@ -4,6 +4,7 @@ import { getLocations, loadSettings } from '../storage/db.js' import { fetchForecast } from '../api/weather.js' +import { fetchForecastNWS } from '../api/nws.js' export class AppStore { // Location state @@ -31,6 +32,7 @@ export class AppStore { // Settings settings = $state({ units: 'metric', + source: 'open-meteo', alertsEnabled: true, alertThresholds: { precip: 70, @@ -108,7 +110,21 @@ export class AppStore { this.error = '' try { - const data = await fetchForecast(location.lat, location.lon, this.settings.units) + const units = this.settings.units + const source = this.settings.source || 'open-meteo' + let data + if (source === 'nws') { + try { + data = await fetchForecastNWS(location.lat, location.lon, units) + } catch (nwsErr) { + // NWS is US-only and can be unavailable — fall back to Open-Meteo + // so the app keeps working rather than breaking. + console.warn('NWS unavailable, falling back to Open-Meteo:', nwsErr.message) + data = await fetchForecast(location.lat, location.lon, units) + } + } else { + data = await fetchForecast(location.lat, location.lon, units) + } this.forecastData = data // Update the location name if we got a timezone abbreviation diff --git a/tests/fixtures/nws/forecast.json b/tests/fixtures/nws/forecast.json new file mode 100644 index 0000000..91e5bdb --- /dev/null +++ b/tests/fixtures/nws/forecast.json @@ -0,0 +1,319 @@ +{ + "@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": [ + [ + [ + -95.7922, + 36.3555 + ], + [ + -95.7924, + 36.3779 + ], + [ + -95.8201, + 36.3778 + ], + [ + -95.82, + 36.3554 + ], + [ + -95.7922, + 36.3555 + ] + ] + ] + }, + "properties": { + "units": "us", + "forecastGenerator": "BaselineForecastGenerator", + "generatedAt": "2026-08-16T20:09:21+00:00", + "updateTime": "2026-08-16T19:50:51+00:00", + "validTimes": "2026-08-16T17:00:00+00:00/P7DT8H", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 196.9008 + }, + "periods": [ + { + "number": 1, + "name": "This Afternoon", + "startTime": "2026-08-16T15:00:00-05:00", + "endTime": "2026-08-16T18:00:00-05:00", + "isDaytime": true, + "temperature": 103, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 17 + }, + "windSpeed": "10 mph", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/tsra_hi,20?size=medium", + "shortForecast": "Slight Chance Showers And Thunderstorms", + "detailedForecast": "A slight chance of showers and thunderstorms after 4pm. Mostly sunny, with a high near 103. Heat index values as high as 112. West wind around 10 mph. Chance of precipitation is 20%." + }, + { + "number": 2, + "name": "Tonight", + "startTime": "2026-08-16T18:00:00-05:00", + "endTime": "2026-08-17T06:00:00-05:00", + "isDaytime": false, + "temperature": 75, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 24 + }, + "windSpeed": "5 mph", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=medium", + "shortForecast": "Slight Chance Showers And Thunderstorms", + "detailedForecast": "A slight chance of showers and thunderstorms. Partly cloudy, with a low around 75. Heat index values as high as 110. North wind around 5 mph. Chance of precipitation is 20%. New rainfall amounts less than a tenth of an inch possible." + }, + { + "number": 3, + "name": "Monday", + "startTime": "2026-08-17T06:00:00-05:00", + "endTime": "2026-08-17T18:00:00-05:00", + "isDaytime": true, + "temperature": 94, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 24 + }, + "windSpeed": "5 to 10 mph", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/day/tsra_hi,20?size=medium", + "shortForecast": "Slight Chance Showers And Thunderstorms", + "detailedForecast": "A slight chance of showers and thunderstorms. Mostly sunny, with a high near 94. Heat index values as high as 105. North wind 5 to 10 mph. Chance of precipitation is 20%. New rainfall amounts less than a tenth of an inch possible." + }, + { + "number": 4, + "name": "Monday Night", + "startTime": "2026-08-17T18:00:00-05:00", + "endTime": "2026-08-18T06:00:00-05:00", + "isDaytime": false, + "temperature": 73, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "windSpeed": "0 to 5 mph", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20/few?size=medium", + "shortForecast": "Slight Chance Showers And Thunderstorms then Mostly Clear", + "detailedForecast": "A slight chance of showers and thunderstorms before 7pm. Mostly clear, with a low around 73. Heat index values as high as 103. Northeast wind 0 to 5 mph. Chance of precipitation is 20%." + }, + { + "number": 5, + "name": "Tuesday", + "startTime": "2026-08-18T06:00:00-05:00", + "endTime": "2026-08-18T18:00:00-05:00", + "isDaytime": true, + "temperature": 101, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 11 + }, + "windSpeed": "0 to 5 mph", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/day/hot?size=medium", + "shortForecast": "Sunny", + "detailedForecast": "Sunny, with a high near 101. Southeast wind 0 to 5 mph." + }, + { + "number": 6, + "name": "Tuesday Night", + "startTime": "2026-08-18T18:00:00-05:00", + "endTime": "2026-08-19T06:00:00-05:00", + "isDaytime": false, + "temperature": 79, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "windSpeed": "5 to 10 mph", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/few?size=medium", + "shortForecast": "Mostly Clear", + "detailedForecast": "Mostly clear, with a low around 79. South wind 5 to 10 mph." + }, + { + "number": 7, + "name": "Wednesday", + "startTime": "2026-08-19T06:00:00-05:00", + "endTime": "2026-08-19T18:00:00-05:00", + "isDaytime": true, + "temperature": 104, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "windSpeed": "10 mph", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/hot?size=medium", + "shortForecast": "Sunny", + "detailedForecast": "Sunny, with a high near 104. Southwest wind around 10 mph." + }, + { + "number": 8, + "name": "Wednesday Night", + "startTime": "2026-08-19T18:00:00-05:00", + "endTime": "2026-08-20T06:00:00-05:00", + "isDaytime": false, + "temperature": 76, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 12 + }, + "windSpeed": "10 to 15 mph", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/few?size=medium", + "shortForecast": "Mostly Clear", + "detailedForecast": "Mostly clear, with a low around 76. South wind 10 to 15 mph, with gusts as high as 20 mph." + }, + { + "number": 9, + "name": "Thursday", + "startTime": "2026-08-20T06:00:00-05:00", + "endTime": "2026-08-20T18:00:00-05:00", + "isDaytime": true, + "temperature": 98, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 12 + }, + "windSpeed": "5 to 10 mph", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/day/sct?size=medium", + "shortForecast": "Mostly Sunny", + "detailedForecast": "Mostly sunny, with a high near 98. Northeast wind 5 to 10 mph." + }, + { + "number": 10, + "name": "Thursday Night", + "startTime": "2026-08-20T18:00:00-05:00", + "endTime": "2026-08-21T06:00:00-05:00", + "isDaytime": false, + "temperature": 72, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 7 + }, + "windSpeed": "5 to 10 mph", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/night/few?size=medium", + "shortForecast": "Mostly Clear", + "detailedForecast": "Mostly clear, with a low around 72. Northeast wind 5 to 10 mph." + }, + { + "number": 11, + "name": "Friday", + "startTime": "2026-08-21T06:00:00-05:00", + "endTime": "2026-08-21T18:00:00-05:00", + "isDaytime": true, + "temperature": 97, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 7 + }, + "windSpeed": "5 mph", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/day/few?size=medium", + "shortForecast": "Sunny", + "detailedForecast": "Sunny, with a high near 97. Northeast wind around 5 mph." + }, + { + "number": 12, + "name": "Friday Night", + "startTime": "2026-08-21T18:00:00-05:00", + "endTime": "2026-08-22T06:00:00-05:00", + "isDaytime": false, + "temperature": 72, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "windSpeed": "5 to 10 mph", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/night/few?size=medium", + "shortForecast": "Mostly Clear", + "detailedForecast": "Mostly clear, with a low around 72. Northeast wind 5 to 10 mph." + }, + { + "number": 13, + "name": "Saturday", + "startTime": "2026-08-22T06:00:00-05:00", + "endTime": "2026-08-22T18:00:00-05:00", + "isDaytime": true, + "temperature": 99, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 4 + }, + "windSpeed": "5 mph", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/day/few?size=medium", + "shortForecast": "Sunny", + "detailedForecast": "Sunny, with a high near 99. East wind around 5 mph." + }, + { + "number": 14, + "name": "Saturday Night", + "startTime": "2026-08-22T18:00:00-05:00", + "endTime": "2026-08-23T06:00:00-05:00", + "isDaytime": false, + "temperature": 73, + "temperatureUnit": "F", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 5 + }, + "windSpeed": "5 to 10 mph", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/few?size=medium", + "shortForecast": "Mostly Clear", + "detailedForecast": "Mostly clear, with a low around 73. East wind 5 to 10 mph." + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/nws/grid.json b/tests/fixtures/nws/grid.json new file mode 100644 index 0000000..7e424ed --- /dev/null +++ b/tests/fixtures/nws/grid.json @@ -0,0 +1 @@ +{"properties": {"elevation": {"value": 196.9008}}} \ No newline at end of file diff --git a/tests/fixtures/nws/hourly.json b/tests/fixtures/nws/hourly.json new file mode 100644 index 0000000..185eee1 --- /dev/null +++ b/tests/fixtures/nws/hourly.json @@ -0,0 +1 @@ +{"@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": [[[-95.7922, 36.3555], [-95.7924, 36.3779], [-95.8201, 36.3778], [-95.82, 36.3554], [-95.7922, 36.3555]]]}, "properties": {"units": "us", "forecastGenerator": "HourlyForecastGenerator", "generatedAt": "2026-08-16T20:09:22+00:00", "updateTime": "2026-08-16T19:50:51+00:00", "validTimes": "2026-08-16T17:00:00+00:00/P7DT8H", "elevation": {"unitCode": "wmoUnit:m", "value": 196.9008}, "periods": [{"number": 1, "name": "", "startTime": "2026-08-16T15:00:00-05:00", "endTime": "2026-08-16T16:00:00-05:00", "isDaytime": true, "temperature": 102, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 10}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 20.555555555555557}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 35}, "windSpeed": "10 mph", "windDirection": "SW", "icon": "https://api.weather.gov/icons/land/day/hot?size=small", "shortForecast": "Mostly Sunny", "detailedForecast": ""}, {"number": 2, "name": "", "startTime": "2026-08-16T16:00:00-05:00", "endTime": "2026-08-16T17:00:00-05:00", "isDaytime": true, "temperature": 103, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 17}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 20.555555555555557}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 34}, "windSpeed": "10 mph", "windDirection": "W", "icon": "https://api.weather.gov/icons/land/day/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 3, "name": "", "startTime": "2026-08-16T17:00:00-05:00", "endTime": "2026-08-16T18:00:00-05:00", "isDaytime": true, "temperature": 103, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 17}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 21.11111111111111}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 35}, "windSpeed": "10 mph", "windDirection": "W", "icon": "https://api.weather.gov/icons/land/day/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 4, "name": "", "startTime": "2026-08-16T18:00:00-05:00", "endTime": "2026-08-16T19:00:00-05:00", "isDaytime": false, "temperature": 102, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 17}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 21.11111111111111}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 36}, "windSpeed": "5 mph", "windDirection": "W", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 5, "name": "", "startTime": "2026-08-16T19:00:00-05:00", "endTime": "2026-08-16T20:00:00-05:00", "isDaytime": false, "temperature": 100, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 22}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 21.666666666666668}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 40}, "windSpeed": "5 mph", "windDirection": "NW", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 6, "name": "", "startTime": "2026-08-16T20:00:00-05:00", "endTime": "2026-08-16T21:00:00-05:00", "isDaytime": false, "temperature": 97, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 22}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 21.666666666666668}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 43}, "windSpeed": "5 mph", "windDirection": "NW", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 7, "name": "", "startTime": "2026-08-16T21:00:00-05:00", "endTime": "2026-08-16T22:00:00-05:00", "isDaytime": false, "temperature": 91, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 22}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.22222222222222}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 54}, "windSpeed": "5 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 8, "name": "", "startTime": "2026-08-16T22:00:00-05:00", "endTime": "2026-08-16T23:00:00-05:00", "isDaytime": false, "temperature": 89, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 16}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.22222222222222}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 57}, "windSpeed": "5 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 9, "name": "", "startTime": "2026-08-16T23:00:00-05:00", "endTime": "2026-08-17T00:00:00-05:00", "isDaytime": false, "temperature": 87, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 16}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 63}, "windSpeed": "5 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 10, "name": "", "startTime": "2026-08-17T00:00:00-05:00", "endTime": "2026-08-17T01:00:00-05:00", "isDaytime": false, "temperature": 85, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 16}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 67}, "windSpeed": "5 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 11, "name": "", "startTime": "2026-08-17T01:00:00-05:00", "endTime": "2026-08-17T02:00:00-05:00", "isDaytime": false, "temperature": 83, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 24}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.333333333333332}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 74}, "windSpeed": "5 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 12, "name": "", "startTime": "2026-08-17T02:00:00-05:00", "endTime": "2026-08-17T03:00:00-05:00", "isDaytime": false, "temperature": 81, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 24}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.333333333333332}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 79}, "windSpeed": "5 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 13, "name": "", "startTime": "2026-08-17T03:00:00-05:00", "endTime": "2026-08-17T04:00:00-05:00", "isDaytime": false, "temperature": 80, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 24}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.333333333333332}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 82}, "windSpeed": "5 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 14, "name": "", "startTime": "2026-08-17T04:00:00-05:00", "endTime": "2026-08-17T05:00:00-05:00", "isDaytime": false, "temperature": 78, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 24}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.333333333333332}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 88}, "windSpeed": "5 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 15, "name": "", "startTime": "2026-08-17T05:00:00-05:00", "endTime": "2026-08-17T06:00:00-05:00", "isDaytime": false, "temperature": 76, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 24}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.333333333333332}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 94}, "windSpeed": "5 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 16, "name": "", "startTime": "2026-08-17T06:00:00-05:00", "endTime": "2026-08-17T07:00:00-05:00", "isDaytime": true, "temperature": 75, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 24}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.333333333333332}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 97}, "windSpeed": "10 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/day/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 17, "name": "", "startTime": "2026-08-17T07:00:00-05:00", "endTime": "2026-08-17T08:00:00-05:00", "isDaytime": true, "temperature": 75, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 12}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.333333333333332}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 97}, "windSpeed": "10 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", "shortForecast": "Partly Sunny", "detailedForecast": ""}, {"number": 18, "name": "", "startTime": "2026-08-17T08:00:00-05:00", "endTime": "2026-08-17T09:00:00-05:00", "isDaytime": true, "temperature": 76, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 12}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.333333333333332}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 94}, "windSpeed": "10 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", "shortForecast": "Partly Sunny", "detailedForecast": ""}, {"number": 19, "name": "", "startTime": "2026-08-17T09:00:00-05:00", "endTime": "2026-08-17T10:00:00-05:00", "isDaytime": true, "temperature": 79, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 12}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.88888888888889}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 88}, "windSpeed": "10 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", "shortForecast": "Partly Sunny", "detailedForecast": ""}, {"number": 20, "name": "", "startTime": "2026-08-17T10:00:00-05:00", "endTime": "2026-08-17T11:00:00-05:00", "isDaytime": true, "temperature": 83, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 8}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.88888888888889}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 77}, "windSpeed": "10 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/day/sct?size=small", "shortForecast": "Mostly Sunny", "detailedForecast": ""}, {"number": 21, "name": "", "startTime": "2026-08-17T11:00:00-05:00", "endTime": "2026-08-17T12:00:00-05:00", "isDaytime": true, "temperature": 85, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 8}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.88888888888889}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 72}, "windSpeed": "10 mph", "windDirection": "N", "icon": "https://api.weather.gov/icons/land/day/sct?size=small", "shortForecast": "Mostly Sunny", "detailedForecast": ""}, {"number": 22, "name": "", "startTime": "2026-08-17T12:00:00-05:00", "endTime": "2026-08-17T13:00:00-05:00", "isDaytime": true, "temperature": 88, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 8}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 24.444444444444443}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 68}, "windSpeed": "10 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/day/sct?size=small", "shortForecast": "Mostly Sunny", "detailedForecast": ""}, {"number": 23, "name": "", "startTime": "2026-08-17T13:00:00-05:00", "endTime": "2026-08-17T14:00:00-05:00", "isDaytime": true, "temperature": 90, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 4}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.88888888888889}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 62}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/day/sct?size=small", "shortForecast": "Mostly Sunny", "detailedForecast": ""}, {"number": 24, "name": "", "startTime": "2026-08-17T14:00:00-05:00", "endTime": "2026-08-17T15:00:00-05:00", "isDaytime": true, "temperature": 91, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 4}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.88888888888889}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 60}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/day/sct?size=small", "shortForecast": "Mostly Sunny", "detailedForecast": ""}, {"number": 25, "name": "", "startTime": "2026-08-17T15:00:00-05:00", "endTime": "2026-08-17T16:00:00-05:00", "isDaytime": true, "temperature": 92, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 4}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.88888888888889}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 58}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/day/sct?size=small", "shortForecast": "Mostly Sunny", "detailedForecast": ""}, {"number": 26, "name": "", "startTime": "2026-08-17T16:00:00-05:00", "endTime": "2026-08-17T17:00:00-05:00", "isDaytime": true, "temperature": 94, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 16}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.333333333333332}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 53}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/day/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 27, "name": "", "startTime": "2026-08-17T17:00:00-05:00", "endTime": "2026-08-17T18:00:00-05:00", "isDaytime": true, "temperature": 94, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 16}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.333333333333332}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 53}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/day/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 28, "name": "", "startTime": "2026-08-17T18:00:00-05:00", "endTime": "2026-08-17T19:00:00-05:00", "isDaytime": false, "temperature": 94, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 16}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 51}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=small", "shortForecast": "Slight Chance Showers And Thunderstorms", "detailedForecast": ""}, {"number": 29, "name": "", "startTime": "2026-08-17T19:00:00-05:00", "endTime": "2026-08-17T20:00:00-05:00", "isDaytime": false, "temperature": 92, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 11}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.22222222222222}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 52}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/night/few?size=small", "shortForecast": "Mostly Clear", "detailedForecast": ""}, {"number": 30, "name": "", "startTime": "2026-08-17T20:00:00-05:00", "endTime": "2026-08-17T21:00:00-05:00", "isDaytime": false, "temperature": 88, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 11}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 61}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/night/few?size=small", "shortForecast": "Mostly Clear", "detailedForecast": ""}, {"number": 31, "name": "", "startTime": "2026-08-17T21:00:00-05:00", "endTime": "2026-08-17T22:00:00-05:00", "isDaytime": false, "temperature": 85, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 11}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 67}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/night/few?size=small", "shortForecast": "Mostly Clear", "detailedForecast": ""}, {"number": 32, "name": "", "startTime": "2026-08-17T22:00:00-05:00", "endTime": "2026-08-17T23:00:00-05:00", "isDaytime": false, "temperature": 82, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 3}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 74}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/night/few?size=small", "shortForecast": "Mostly Clear", "detailedForecast": ""}, {"number": 33, "name": "", "startTime": "2026-08-17T23:00:00-05:00", "endTime": "2026-08-18T00:00:00-05:00", "isDaytime": false, "temperature": 81, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 3}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 23.333333333333332}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 79}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/night/few?size=small", "shortForecast": "Mostly Clear", "detailedForecast": ""}, {"number": 34, "name": "", "startTime": "2026-08-18T00:00:00-05:00", "endTime": "2026-08-18T01:00:00-05:00", "isDaytime": false, "temperature": 79, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 3}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 82}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/night/few?size=small", "shortForecast": "Mostly Clear", "detailedForecast": ""}, {"number": 35, "name": "", "startTime": "2026-08-18T01:00:00-05:00", "endTime": "2026-08-18T02:00:00-05:00", "isDaytime": false, "temperature": 78, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 8}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 85}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/night/few?size=small", "shortForecast": "Mostly Clear", "detailedForecast": ""}, {"number": 36, "name": "", "startTime": "2026-08-18T02:00:00-05:00", "endTime": "2026-08-18T03:00:00-05:00", "isDaytime": false, "temperature": 76, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 8}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 90}, "windSpeed": "5 mph", "windDirection": "NE", "icon": "https://api.weather.gov/icons/land/night/few?size=small", "shortForecast": "Mostly Clear", "detailedForecast": ""}, {"number": 37, "name": "", "startTime": "2026-08-18T03:00:00-05:00", "endTime": "2026-08-18T04:00:00-05:00", "isDaytime": false, "temperature": 75, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 8}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 94}, "windSpeed": "0 mph", "windDirection": "", "icon": "https://api.weather.gov/icons/land/night/sct?size=small", "shortForecast": "Partly Cloudy", "detailedForecast": ""}, {"number": 38, "name": "", "startTime": "2026-08-18T04:00:00-05:00", "endTime": "2026-08-18T05:00:00-05:00", "isDaytime": false, "temperature": 75, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 11}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 94}, "windSpeed": "0 mph", "windDirection": "", "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", "shortForecast": "Mostly Cloudy", "detailedForecast": ""}, {"number": 39, "name": "", "startTime": "2026-08-18T05:00:00-05:00", "endTime": "2026-08-18T06:00:00-05:00", "isDaytime": false, "temperature": 74, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 11}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 97}, "windSpeed": "0 mph", "windDirection": "", "icon": "https://api.weather.gov/icons/land/night/sct?size=small", "shortForecast": "Partly Cloudy", "detailedForecast": ""}, {"number": 40, "name": "", "startTime": "2026-08-18T06:00:00-05:00", "endTime": "2026-08-18T07:00:00-05:00", "isDaytime": true, "temperature": 73, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 11}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 100}, "windSpeed": "0 mph", "windDirection": "", "icon": "https://api.weather.gov/icons/land/day/sct?size=small", "shortForecast": "Mostly Sunny", "detailedForecast": ""}, {"number": 41, "name": "", "startTime": "2026-08-18T07:00:00-05:00", "endTime": "2026-08-18T08:00:00-05:00", "isDaytime": true, "temperature": 73, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 7}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 100}, "windSpeed": "0 mph", "windDirection": "", "icon": "https://api.weather.gov/icons/land/day/sct?size=small", "shortForecast": "Mostly Sunny", "detailedForecast": ""}, {"number": 42, "name": "", "startTime": "2026-08-18T08:00:00-05:00", "endTime": "2026-08-18T09:00:00-05:00", "isDaytime": true, "temperature": 76, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 7}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 90}, "windSpeed": "0 mph", "windDirection": "", "icon": "https://api.weather.gov/icons/land/day/skc?size=small", "shortForecast": "Sunny", "detailedForecast": ""}, {"number": 43, "name": "", "startTime": "2026-08-18T09:00:00-05:00", "endTime": "2026-08-18T10:00:00-05:00", "isDaytime": true, "temperature": 80, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 7}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 79}, "windSpeed": "5 mph", "windDirection": "SE", "icon": "https://api.weather.gov/icons/land/day/few?size=small", "shortForecast": "Sunny", "detailedForecast": ""}, {"number": 44, "name": "", "startTime": "2026-08-18T10:00:00-05:00", "endTime": "2026-08-18T11:00:00-05:00", "isDaytime": true, "temperature": 84, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 7}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 70}, "windSpeed": "5 mph", "windDirection": "S", "icon": "https://api.weather.gov/icons/land/day/few?size=small", "shortForecast": "Sunny", "detailedForecast": ""}, {"number": 45, "name": "", "startTime": "2026-08-18T11:00:00-05:00", "endTime": "2026-08-18T12:00:00-05:00", "isDaytime": true, "temperature": 88, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 7}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 61}, "windSpeed": "5 mph", "windDirection": "S", "icon": "https://api.weather.gov/icons/land/day/few?size=small", "shortForecast": "Sunny", "detailedForecast": ""}, {"number": 46, "name": "", "startTime": "2026-08-18T12:00:00-05:00", "endTime": "2026-08-18T13:00:00-05:00", "isDaytime": true, "temperature": 91, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 7}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.77777777777778}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 56}, "windSpeed": "5 mph", "windDirection": "S", "icon": "https://api.weather.gov/icons/land/day/few?size=small", "shortForecast": "Sunny", "detailedForecast": ""}, {"number": 47, "name": "", "startTime": "2026-08-18T13:00:00-05:00", "endTime": "2026-08-18T14:00:00-05:00", "isDaytime": true, "temperature": 94, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 0}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 22.22222222222222}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 49}, "windSpeed": "5 mph", "windDirection": "S", "icon": "https://api.weather.gov/icons/land/day/few?size=small", "shortForecast": "Sunny", "detailedForecast": ""}, {"number": 48, "name": "", "startTime": "2026-08-18T14:00:00-05:00", "endTime": "2026-08-18T15:00:00-05:00", "isDaytime": true, "temperature": 97, "temperatureUnit": "F", "temperatureTrend": null, "probabilityOfPrecipitation": {"unitCode": "wmoUnit:percent", "value": 0}, "dewpoint": {"unitCode": "wmoUnit:degC", "value": 21.666666666666668}, "relativeHumidity": {"unitCode": "wmoUnit:percent", "value": 43}, "windSpeed": "5 mph", "windDirection": "S", "icon": "https://api.weather.gov/icons/land/day/few?size=small", "shortForecast": "Sunny", "detailedForecast": ""}]}} \ No newline at end of file diff --git a/tests/fixtures/nws/points.json b/tests/fixtures/nws/points.json new file mode 100644 index 0000000..965bf23 --- /dev/null +++ b/tests/fixtures/nws/points.json @@ -0,0 +1,112 @@ +{ + "@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/36.3567,-95.8146", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -95.8146, + 36.3567 + ] + }, + "properties": { + "@id": "https://api.weather.gov/points/36.3567,-95.8146", + "@type": "wx:Point", + "cwa": "TSA", + "type": "land", + "forecastOffice": "https://api.weather.gov/offices/TSA", + "gridId": "TSA", + "gridX": 48, + "gridY": 114, + "forecast": "https://api.weather.gov/gridpoints/TSA/48,114/forecast", + "forecastHourly": "https://api.weather.gov/gridpoints/TSA/48,114/forecast/hourly", + "forecastGridData": "https://api.weather.gov/gridpoints/TSA/48,114", + "observationStations": "https://api.weather.gov/gridpoints/TSA/48,114/stations", + "relativeLocation": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -95.8617563, + 36.3692625 + ] + }, + "properties": { + "city": "Collinsville", + "state": "OK", + "distance": { + "unitCode": "wmoUnit:m", + "value": 0 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 0 + } + } + }, + "forecastZone": "https://api.weather.gov/zones/forecast/OKZ060", + "county": "https://api.weather.gov/zones/county/OKC143", + "fireWeatherZone": "https://api.weather.gov/zones/fire/OKZ060", + "timeZone": "America/Chicago", + "radarStation": "KINX", + "astronomicalData": { + "sunrise": "2026-08-16T06:42:13-05:00", + "sunset": "2026-08-16T20:12:46-05:00", + "transit": "2026-08-16T13:27:29-05:00", + "civilTwilightBegin": "2026-08-16T06:15:02-05:00", + "civilTwilightEnd": "2026-08-16T20:39:57-05:00", + "nauticalTwilightBegin": "2026-08-16T05:42:19-05:00", + "nauticalTwilightEnd": "2026-08-16T21:12:40-05:00", + "astronomicalTwilightBegin": "2026-08-16T05:07:54-05:00", + "astronomicalTwilightEnd": "2026-08-16T21:47:05-05:00" + }, + "nwr": { + "transmitter": "KIH27", + "sameCode": "040143", + "areaBroadcast": "https://api.weather.gov/radio/KIH27/broadcast", + "pointBroadcast": "https://api.weather.gov/points/36.3567,-95.8146/radio" + } + } +} \ No newline at end of file diff --git a/tests/nws.test.js b/tests/nws.test.js new file mode 100644 index 0000000..b3e133e --- /dev/null +++ b/tests/nws.test.js @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { + compassToDeg, + parseWind, + shortForecastToWmo, + dateKey, + localTimeString, + normalizeNWS, +} from '../src/lib/api/nws.js' + +const FIX = (name) => JSON.parse(readFileSync(path.join('tests/fixtures/nws', name), 'utf8')) + +describe('nws helpers', () => { + it('shortForecastToWmo maps text to WMO codes', () => { + expect(shortForecastToWmo('Sunny')).toBe(0) + expect(shortForecastToWmo('Mostly Sunny')).toBe(2) + expect(shortForecastToWmo('Partly Cloudy')).toBe(2) + expect(shortForecastToWmo('Overcast')).toBe(3) + expect(shortForecastToWmo('Slight Chance Showers And Thunderstorms')).toBe(95) + expect(shortForecastToWmo('Heavy Rain')).toBe(65) + expect(shortForecastToWmo('Light Snow')).toBe(71) + expect(shortForecastToWmo('Areas of Fog')).toBe(45) + }) + + it('compassToDeg maps compass points to degrees', () => { + expect(compassToDeg('N')).toBe(0) + expect(compassToDeg('NE')).toBe(45) + expect(compassToDeg('SW')).toBe(225) + expect(compassToDeg('W')).toBe(270) + expect(compassToDeg(null)).toBeNull() + }) + + it('parseWind takes the max speed from a range string', () => { + expect(parseWind('5 to 10 mph')).toBe(10) + expect(parseWind('12 mph')).toBe(12) + expect(parseWind('gusts up to 20 mph')).toBe(20) + expect(parseWind(null)).toBeNull() + }) + + it('dateKey and localTimeString produce naive local times (no UTC shift)', () => { + const tz = 'America/Chicago' + expect(dateKey('2026-08-16T15:00:00-05:00', tz)).toBe('2026-08-16') + expect(localTimeString('2026-08-16T15:00:00-05:00', tz)).toBe('2026-08-16T15:00') + expect(localTimeString('2026-08-19T15:00:00-05:00', tz)).toMatch(/^2026-08-19T/) + }) +}) + +describe('normalizeNWS (recorded live fixture for Rogers County, OK)', () => { + const points = FIX('points.json') + const forecast = FIX('forecast.json') + const hourly = FIX('hourly.json') + const grid = FIX('grid.json') + const timeZone = points.properties.timeZone + + const data = normalizeNWS({ points, forecast, hourly, grid, timeZone, units: 'imperial' }) + + it('marks the source as nws', () => { + expect(data._source).toBe('nws') + }) + + it('normalizes air high (not heat index) for the 7-day, matching NWS values', () => { + // NWS daytime highs: Today 103, Mon 94, Wed 104 ... (NO 116 outlier) + expect(data.daily.time).toHaveLength(7) + expect(data.daily.temperature_2m_max[0]).toBe(103) // Today + expect(data.daily.temperature_2m_max[1]).toBe(94) // Monday + expect(data.daily.temperature_2m_max[3]).toBe(104) // Wednesday + expect(Math.max(...data.daily.temperature_2m_max)).toBeLessThan(115) + expect(data.daily.temperature_2m_min[0]).toBe(75) + expect(data.daily.precipitation_probability_max[0]).toBe(24) + }) + + it('builds a realistic current from the first hourly period', () => { + expect(data.current.temperature_2m).toBe(102) + expect(data.current.relative_humidity_2m).toBe(35) + expect(data.current.wind_direction_10m).toBe(225) // SW + expect(data.current.time).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/) + }) + + it('normalizes to metric on request', () => { + const m = normalizeNWS({ points, forecast, hourly, grid, timeZone, units: 'metric' }) + expect(m.current.temperature_2m).toBeCloseTo((102 - 32) * 5 / 9, 1) + expect(m.daily.temperature_2m_max[0]).toBeCloseTo((103 - 32) * 5 / 9, 1) + expect(m.current.wind_speed_10m).toBeCloseTo(10 * 1.609344, 1) + }) +})