@@ -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)
+ })
+})