From 15a64501a98b1e9b702e215d5ccb38f0ed1c26ff Mon Sep 17 00:00:00 2001 From: Fraxle Date: Sun, 9 Aug 2026 17:27:45 +0100 Subject: [PATCH] 5.0.8 Adaptive week ahead --- assets/js/app.js | 8 ++- assets/js/compute.js | 152 +++++++++++++++++++++++++++++++++---------- 2 files changed, 121 insertions(+), 39 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index a83438e..9c8d78e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -40,7 +40,7 @@ import { ConfigPanel } from './components/ConfigPanel.js'; import { WelcomeModal } from './components/WelcomeModal.js'; import { RestoreModal } from './components/RestoreModal.js'; import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js'; -import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel } from './compute.js'; +import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel, bestDaysHint } from './compute.js'; import { solarElevationDeg } from './physics.js'; import { exportDayXls } from './export.js'; @@ -534,7 +534,9 @@ export function UTCIForecast() { ); // Week-scoped, so it is kept out of the day-scoped "At a glance" panel and // rendered in its own "The Week Ahead" box beneath it. - const weekAhead = computeBestDay(days, nowLocalISO); + // Scored on the profile's own main field, so the panel answers "when should + // I drive / when is the house bearable", not always "when is it nice out". + const weekAhead = computeBestDay(days, nowLocalISO, activeProfile, outdoorsVariant); // Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026". const glanceDate = (() => { @@ -1619,7 +1621,7 @@ export function UTCIForecast() { ${titleCaseText(label)} ${titleCaseText(value)} ${score != null && html` - + diff --git a/assets/js/compute.js b/assets/js/compute.js index 2a8e0fa..023156e 100644 --- a/assets/js/compute.js +++ b/assets/js/compute.js @@ -17,7 +17,7 @@ import { calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp, calcFurSurfaceTempPass, } from './physics.js'; -import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS } from './utils.js'; +import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS, PET_BANDS } from './utils.js'; import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js'; export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) { @@ -1005,6 +1005,16 @@ function bestRun(arr, pred) { const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist +// Said when no day qualifies, phrased for what was actually being judged - +// "no settled outdoor windows" is meaningless when the panel was reading a +// car cabin. +const EMPTY_VALUE = { + utciAdj: 'no settled outdoor windows', + vehicleT: 'no comfortable cabin windows', + indoorT: 'no comfortable indoor windows', + furSurfaceT: 'no safe windows for a pet outside', +}; + // ── The four bands ─────────────────────────────────────────────────────── // Each is [ideal, limit]: at the ideal end an hour scores full marks on that // measure, at the limit it scores zero, and past the limit the hour is not a @@ -1020,13 +1030,18 @@ const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist // Read off UTCI_BANDS rather than written as numbers, so the panel and the // table's colour bands can never drift apart: today that is a 19-27 plateau // ramping out to 15 and 32. -const bandMax = (label) => UTCI_BANDS.find(b => b.label === label).max; -const SUNSOAK = { - min: bandMax('Chilly'), // bottom of Cool - score 0 - flatLo: bandMax('Cool'), // bottom of Comfortable - score 1 - flatHi: bandMax('Warm'), // top of Warm - score 1 - max: bandMax('Caution'), // top of Caution - score 0 -}; +const bandMax = (bands, label) => bands.find(b => b.label === label).max; +const plateau = (bands) => ({ + min: bandMax(bands, 'Chilly'), // bottom of Cool - score 0 + flatLo: bandMax(bands, 'Cool'), // bottom of Comfortable - score 1 + flatHi: bandMax(bands, 'Warm'), // top of Warm - score 1 + max: bandMax(bands, 'Caution'), // top of Caution - score 0 +}); +const SUNSOAK = plateau(UTCI_BANDS); +// Pets are judged on fur-surface temperature, which is coloured off PET_BANDS +// in the table - reading the plateau off the same array keeps the panel and +// the cell colours in step there too. +const PET_PLATEAU = plateau(PET_BANDS); const BAND_GUST = { ideal: 15, limit: 25 }; // mph const BAND_RAIN = { ideal: 0, limit: 25 }; // % chance const BAND_SOIL = { ideal: 0.10, limit: 0.25 }; // m3/m3 - dry ground to soggy @@ -1039,44 +1054,84 @@ function bandScore(v, { ideal, limit }) { return Math.max(0, Math.min(1, f)); } -function sunsoakScore(v) { +function plateauScore(v, pl) { if (v == null) return null; - if (v >= SUNSOAK.flatLo && v <= SUNSOAK.flatHi) return 1; - const f = v < SUNSOAK.flatLo - ? (v - SUNSOAK.min) / (SUNSOAK.flatLo - SUNSOAK.min) - : (SUNSOAK.max - v) / (SUNSOAK.max - SUNSOAK.flatHi); + if (v >= pl.flatLo && v <= pl.flatHi) return 1; + const f = v < pl.flatLo + ? (v - pl.min) / (pl.flatLo - pl.min) + : (pl.max - v) / (pl.max - pl.flatHi); return Math.max(0, Math.min(1, f)); } -function comfortableHour(r) { - if (!(r.elev > 0)) return false; // daylight only - if (r.utciAdj == null) return false; - if (r.utciAdj < SUNSOAK.min || r.utciAdj > SUNSOAK.max) return false; - if ((r.precipProb ?? 0) > BAND_RAIN.limit) return false; - if ((r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) return false; +// ── What "a good window" means for the active profile ──────────────────── +// The panel used to answer one question - "would I enjoy standing outside" +// - whatever profile was selected, which made it useless to the very users +// who had told the app what they were doing. It now scores the SAME field the +// rest of the app is built around for that profile (the day tabs, the dial, +// the main table column), so "the week ahead" answers the question the +// profile implies: when to drive, when the house is bearable, when to walk +// the dog. +// +// thermal - weight on the profile's main temperature field +// rain / gust / soil - the outdoor nuisances, only where they can matter +// +// Driving keeps rain and gust (both are what makes a journey unpleasant or +// unsafe) and drops soil, which says nothing about a road. Indoors drops all +// three: rain on the roof does not change whether the bedroom is sleepable. +// Daylight-only is likewise a solar concern - a cool cabin at 6am is a real +// answer to "when should I drive", so vehicle and indoor windows run around +// the clock. +function bestDayCriteria(profile, variant) { + const { mainField } = deriveProfileMain(profile, variant); + switch (mainField) { + case 'vehicleT': + return { field: 'vehicleT', pl: SUNSOAK, daylightOnly: false, + w: { thermal: 0.55, rain: 0.25, gust: 0.20, soil: 0 } }; + case 'indoorT': + return { field: 'indoorT', pl: SUNSOAK, daylightOnly: false, + w: { thermal: 1, rain: 0, gust: 0, soil: 0 } }; + case 'furSurfaceT': + return { field: 'furSurfaceT', pl: PET_PLATEAU, daylightOnly: true, + w: { thermal: 0.55, rain: 0.30, gust: 0.15, soil: 0 } }; + default: + return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: true, + w: { thermal: 0.40, rain: 0.25, gust: 0.20, soil: 0.15 } }; + } +} + +function comfortableHour(r, crit) { + if (crit.daylightOnly && !(r.elev > 0)) return false; + const t = r[crit.field]; + if (t == null) return false; + if (t < crit.pl.min || t > crit.pl.max) return false; + // A measure that carries no weight for this profile can't exclude an hour + // either, or an indoor window would still be cut short by a shower. + if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) return false; + if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) return false; // Soil comes from a separate feed and is null wherever that fetch fails or // the model has no coverage - treat missing as passing, or a soil outage // would silently empty the panel everywhere. Only the wet end excludes: dust // -dry ground is a poor day out for the garden, not for the person on it. - if (r.soilM != null && r.soilM > BAND_SOIL.limit) return false; + if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) return false; return true; } // How good an hour is WITHIN the bands, 0-1. Weighted by how much each // measure actually decides whether the day was worth going out for. -function hourQuality(r) { +function hourQuality(r, crit) { const parts = [ - [sunsoakScore(r.utciAdj), 0.40], - [bandScore(r.precipProb ?? 0, BAND_RAIN), 0.25], - [bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), 0.20], - [bandScore(r.soilM, BAND_SOIL), 0.15], - ].filter(([v]) => v != null); + [plateauScore(r[crit.field], crit.pl), crit.w.thermal], + [bandScore(r.precipProb ?? 0, BAND_RAIN), crit.w.rain], + [bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), crit.w.gust], + [bandScore(r.soilM, BAND_SOIL), crit.w.soil], + ].filter(([v, w]) => v != null && w > 0); const wsum = parts.reduce((s, [, w]) => s + w, 0); return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0; } -export function computeBestDay(weekDays, nowLocalISO) { +export function computeBestDay(weekDays, nowLocalISO, profile, variant) { if (!weekDays || weekDays.length === 0) return []; + const crit = bestDayCriteria(profile, variant); const candidates = []; for (const day of weekDays.slice(0, 7)) { @@ -1088,7 +1143,7 @@ export function computeBestDay(weekDays, nowLocalISO) { let start = -1, len = 0, dayBest = null; for (let i = 0; i < rows.length; i++) { - if (comfortableHour(rows[i])) { + if (comfortableHour(rows[i], crit)) { if (start < 0) start = i; len++; if (!dayBest || len > dayBest.len) dayBest = { start, len, end: i }; @@ -1102,21 +1157,26 @@ export function computeBestDay(weekDays, nowLocalISO) { // temperate summer week half of them qualify dawn to dusk, and picking // arbitrarily among those is a coin toss. const run = rows.slice(dayBest.start, dayBest.end + 1); - const quality = run.reduce((s, r) => s + hourQuality(r), 0) / run.length; + const quality = run.reduce((s, r) => s + hourQuality(r, crit), 0) / run.length; candidates.push({ len: dayBest.len, quality, key: day.key, from: rows[dayBest.start], to: rows[dayBest.end], - daylight: rows.filter(r => r.elev > 0).length, + // What the window is measured AGAINST: the daylight for a profile that + // only counts daylight hours, otherwise the whole day. Using daylight + // for a round-the-clock profile would let a 14-hour overnight window + // score over 100% of a 16-hour day and print "all day" for a spell that + // ends at breakfast. + usable: crit.daylightOnly ? rows.filter(r => r.elev > 0).length : rows.length, }); } // Nothing qualifying is itself worth saying - an empty panel reads as a bug, // and "don't bother this week" is a real answer to the question being asked. if (candidates.length === 0) { return [{ - icon: '🌧️', + icon: crit.field === 'utciAdj' ? '🌧️' : '🚫', label: 'Next 7 days', - value: 'no settled outdoor windows', + value: EMPTY_VALUE[crit.field] ?? EMPTY_VALUE.utciAdj, alert: false, }]; } @@ -1132,7 +1192,7 @@ export function computeBestDay(weekDays, nowLocalISO) { // window of perfect ones are both worth knowing about, and letting either // half dominate hides one of them. for (const c of candidates) { - const span = c.daylight > 0 ? Math.min(c.len / c.daylight, 1) : 0; + const span = c.usable > 0 ? Math.min(c.len / c.usable, 1) : 0; c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality)); } @@ -1160,7 +1220,7 @@ export function computeBestDay(weekDays, nowLocalISO) { // A run spanning nearly all the daylight is better said than shown: printing // "6am - 9pm" makes the reader parse a time range to learn "all day". - const window = c.daylight > 0 && c.len >= c.daylight - 1 + const window = c.usable > 0 && c.len >= c.usable - 1 ? 'all day' : `${hh(c.from.iso)} – ${hh(endIso)}`; @@ -1182,9 +1242,29 @@ export function computeBestDay(weekDays, nowLocalISO) { // Header line for the panel: names what is being judged when a variant makes // that non-obvious, otherwise the plain framing. export function bestDaysLabel(profile, variant) { + const { mainField, mainLabel } = deriveProfileMain(profile, variant); + // The vehicle and home profiles have no variant to name, but "best times + // out" would be plainly wrong for them - the panel is now reading the cabin + // and the room, not the pavement. + if (profile === 'vehicle') return 'Best times to drive'; + if (profile === 'home') return 'Best times indoors'; + if (profile === 'pets') return 'Best times for pets'; return profile === 'outdoors' && variant - ? `Best times for ${deriveProfileMain(profile, variant).mainLabel}` - : 'Best times out'; + ? `Best times for ${mainLabel}` + : (mainField === 'utciAdj' ? 'Best times out' : `Best times for ${mainLabel}`); +} + +// Tooltip on the score bar. It has to name the field being scored, or a +// driver reads "temperatures nearer 20 °C" against the outside air and +// concludes the panel is broken. +export function bestDaysHint(profile, variant, score) { + const { mainField } = deriveProfileMain(profile, variant); + const what = { + vehicleT: 'an ideal driving day — longer stretches with a comfortable cabin score higher', + indoorT: 'an ideal day indoors — longer stretches with the building in comfortable range score higher', + furSurfaceT: 'an ideal day for a pet outside — longer stretches with fur and ground temperatures in safe range score higher', + }[mainField] ?? 'an ideal day out — longer comfortable stretches and temperatures nearer 20 °C score higher'; + return `${score}% of ${what}`; } export function computeCropAdvice(weekDays, lat) {