From 2cee923f4ad6cd3e3fb5b4551ec349455b5bdb3c Mon Sep 17 00:00:00 2001 From: adamecker Date: Fri, 6 Nov 2026 16:48:13 +0000 Subject: [PATCH] feat(weather): add precipitation probability to the payload OWM /forecast items carry pop (0-1); surface it as precipitationProbability (0-100) on forecast[] (daily max) and hourly[], via payload.js and summarizeForecast. Home Assistant has no equivalent and the demo snapshot has none, so both stay null and the client hides the field when absent. --- server/services/weather/openweathermap.js | 11 +++++ server/services/weather/payload.js | 13 +++++ server/tests/weatherProviders.test.js | 59 +++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/server/services/weather/openweathermap.js b/server/services/weather/openweathermap.js index 540ec1a0..e6a28e9a 100644 --- a/server/services/weather/openweathermap.js +++ b/server/services/weather/openweathermap.js @@ -167,6 +167,9 @@ function summarizeForecast(list, timezoneOffsetSeconds) { const existing = byDay.get(dayKey); const precipitation = item.rain ? item.rain['3h'] || 0 : 0; + // OWM /forecast items carry pop as a 0-1 fraction; the payload + // contract is 0-100, and null when the provider says nothing. + const pop = Number.isFinite(item.pop) ? Math.round(item.pop * 100) : null; if (!existing) { byDay.set(dayKey, { @@ -176,11 +179,18 @@ function summarizeForecast(list, timezoneOffsetSeconds) { condition: conditionFromOwm(item.weather?.[0]?.id, item.weather?.[0]?.icon), description: item.weather?.[0]?.description || null, precipitation, + precipitationProbability: pop, }); } else { existing.high = Math.max(existing.high, item.main.temp_max); existing.low = Math.min(existing.low, item.main.temp_min); existing.precipitation += precipitation; + // The day's chance is the likeliest 3-hour block in it. + if (pop !== null) { + existing.precipitationProbability = existing.precipitationProbability === null + ? pop + : Math.max(existing.precipitationProbability, pop); + } } if (hourly.length < 8) { @@ -188,6 +198,7 @@ function summarizeForecast(list, timezoneOffsetSeconds) { timestamp: item.dt, temp: item.main.temp, precipitation, + precipitationProbability: pop, }); } } diff --git a/server/services/weather/payload.js b/server/services/weather/payload.js index 7e06e57f..48108f5f 100644 --- a/server/services/weather/payload.js +++ b/server/services/weather/payload.js @@ -101,6 +101,10 @@ function validatePayload(payload) { if (!CONDITION_SET.has(day?.condition)) { problems.push(`forecast[${index}].condition "${day?.condition}" is not in the vocabulary`); } + if (day?.precipitationProbability !== null && day?.precipitationProbability !== undefined + && (!isFiniteNumber(day.precipitationProbability) || day.precipitationProbability < 0 || day.precipitationProbability > 100)) { + problems.push(`forecast[${index}].precipitationProbability must be 0-100 when present`); + } }); } @@ -111,6 +115,10 @@ function validatePayload(payload) { if (!isFiniteNumber(point?.timestamp)) { problems.push(`hourly[${index}].timestamp must be a unix seconds number`); } + if (point?.precipitationProbability !== null && point?.precipitationProbability !== undefined + && (!isFiniteNumber(point.precipitationProbability) || point.precipitationProbability < 0 || point.precipitationProbability > 100)) { + problems.push(`hourly[${index}].precipitationProbability must be 0-100 when present`); + } }); } @@ -164,11 +172,16 @@ function buildPayload({ condition: normalizeCondition(day.condition), description: day.description || null, precipitation: optionalNumber(day.precipitation), + // 0-100. OpenWeatherMap supplies it; Home Assistant has no + // equivalent in the standard forecast attributes and the demo + // snapshot has none, so null there and the client hides it. + precipitationProbability: optionalNumber(day.precipitationProbability), })), hourly: hourly.map((point) => ({ timestamp: point.timestamp, temp: optionalNumber(point.temp), precipitation: optionalNumber(point.precipitation), + precipitationProbability: optionalNumber(point.precipitationProbability), })), airQuality: airQuality ? { diff --git a/server/tests/weatherProviders.test.js b/server/tests/weatherProviders.test.js index 29fca2b6..bd0696f8 100644 --- a/server/tests/weatherProviders.test.js +++ b/server/tests/weatherProviders.test.js @@ -131,6 +131,65 @@ test('the 3-hour forecast rolls into daily highs and lows at the location\'s mid assert.equal(typeof hourly[0].timestamp, 'number'); }); +test('summarizeForecast carries the OWM pop fraction as a 0-100 probability', () => { + const base = Date.UTC(2026, 6, 8, 5, 0, 0) / 1000; // 00:00 Eastern, so i<8 is one local day + const list = []; + for (let i = 0; i < 16; i++) { + list.push({ + dt: base + i * 3 * 3600, + main: { temp: 70, temp_min: 60, temp_max: 80 }, + weather: [{ id: 800, icon: '01d', description: 'clear sky' }], + // The likeliest block of the first local day is 0.6; the second day peaks at 0.25. + pop: i < 8 ? (i === 3 ? 0.6 : 0.2) : (i === 10 ? 0.25 : 0.1), + }); + } + + const { forecast, hourly } = owm.summarizeForecast(list, -5 * 3600); + + assert.equal(hourly[3].precipitationProbability, 60, 'hourly pop is a 0-100 percent'); + assert.equal(hourly[0].precipitationProbability, 20); + assert.equal(forecast[0].precipitationProbability, 60, 'the day takes its likeliest block'); + assert.equal(forecast[1].precipitationProbability, 25); +}); + +test('summarizeForecast leaves probability null when OWM sends no pop', () => { + const base = Date.UTC(2026, 6, 8, 0, 0, 0) / 1000; + const list = [{ + dt: base, + main: { temp: 70, temp_min: 60, temp_max: 80 }, + weather: [{ id: 800, icon: '01d', description: 'clear sky' }], + }]; + + const { forecast, hourly } = owm.summarizeForecast(list, -5 * 3600); + + assert.equal(hourly[0].precipitationProbability, null); + assert.equal(forecast[0].precipitationProbability, null); +}); + +test('the payload contract carries precipitation probability, and null stays valid', () => { + const built = payload.buildPayload({ + provider: 'test', + units: 'imperial', + coordinates: { lat: 1, lon: 2 }, + current: { temp: 50, condition: 'rainy' }, + forecast: [{ date: '2026-07-08', high: 70, low: 60, condition: 'rainy', precipitation: 2, precipitationProbability: 60 }], + hourly: [{ timestamp: 1783536000, temp: 65, precipitation: 0, precipitationProbability: 20 }], + }); + assert.equal(built.forecast[0].precipitationProbability, 60); + assert.equal(built.hourly[0].precipitationProbability, 20); + assert.deepEqual(payload.validatePayload(built), []); + + // A probability outside 0-100 is a provider bug, not a display value. + const bad = { ...built, forecast: [{ ...built.forecast[0], precipitationProbability: 140 }] }; + assert.ok(payload.validatePayload(bad).length > 0, '140% is not a probability'); + + // Providers without a probability (demo, Home Assistant) stay null and valid. + const demo = demoProvider.fetchWeather({ units: 'imperial' }); + assert.ok(demo.forecast.every((day) => day.precipitationProbability === null), 'demo days have no probability'); + assert.ok(demo.hourly.every((point) => point.precipitationProbability === null), 'demo hours have no probability'); + assert.deepEqual(payload.validatePayload(demo), []); +}); + test("the free endpoint's full 40 points give five days, and the chart stays at eight", () => { // OpenWeatherMap's free /forecast is 5 days of 3-hourly points. Starting // at local midnight, that is exactly five local days.