5.0.8
Adaptive week ahead
This commit is contained in:
+5
-3
@@ -40,7 +40,7 @@ import { ConfigPanel } from './components/ConfigPanel.js';
|
|||||||
import { WelcomeModal } from './components/WelcomeModal.js';
|
import { WelcomeModal } from './components/WelcomeModal.js';
|
||||||
import { RestoreModal } from './components/RestoreModal.js';
|
import { RestoreModal } from './components/RestoreModal.js';
|
||||||
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.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 { solarElevationDeg } from './physics.js';
|
||||||
import { exportDayXls } from './export.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
|
// 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.
|
// 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".
|
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
|
||||||
const glanceDate = (() => {
|
const glanceDate = (() => {
|
||||||
@@ -1619,7 +1621,7 @@ export function UTCIForecast() {
|
|||||||
<span class="insight-label">${titleCaseText(label)}</span>
|
<span class="insight-label">${titleCaseText(label)}</span>
|
||||||
<span class="insight-value">${titleCaseText(value)}</span>
|
<span class="insight-value">${titleCaseText(value)}</span>
|
||||||
${score != null && html`
|
${score != null && html`
|
||||||
<span class="week-score" title=${`${score}% of an ideal day out — longer comfortable stretches and temperatures nearer 20 °C score higher`}>
|
<span class="week-score" title=${bestDaysHint(activeProfile, outdoorsVariant, score)}>
|
||||||
<span class="week-score__track">
|
<span class="week-score__track">
|
||||||
<span class="week-score__fill" style=${`width:${score}%;background:${scoreFillColor(score)}`}></span>
|
<span class="week-score__fill" style=${`width:${score}%;background:${scoreFillColor(score)}`}></span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+116
-36
@@ -17,7 +17,7 @@ import {
|
|||||||
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
|
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
|
||||||
calcFurSurfaceTempPass,
|
calcFurSurfaceTempPass,
|
||||||
} from './physics.js';
|
} 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';
|
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';
|
||||||
|
|
||||||
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
|
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_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
|
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 ───────────────────────────────────────────────────────
|
// ── The four bands ───────────────────────────────────────────────────────
|
||||||
// Each is [ideal, limit]: at the ideal end an hour scores full marks on that
|
// 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
|
// 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
|
// 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
|
// table's colour bands can never drift apart: today that is a 19-27 plateau
|
||||||
// ramping out to 15 and 32.
|
// ramping out to 15 and 32.
|
||||||
const bandMax = (label) => UTCI_BANDS.find(b => b.label === label).max;
|
const bandMax = (bands, label) => bands.find(b => b.label === label).max;
|
||||||
const SUNSOAK = {
|
const plateau = (bands) => ({
|
||||||
min: bandMax('Chilly'), // bottom of Cool - score 0
|
min: bandMax(bands, 'Chilly'), // bottom of Cool - score 0
|
||||||
flatLo: bandMax('Cool'), // bottom of Comfortable - score 1
|
flatLo: bandMax(bands, 'Cool'), // bottom of Comfortable - score 1
|
||||||
flatHi: bandMax('Warm'), // top of Warm - score 1
|
flatHi: bandMax(bands, 'Warm'), // top of Warm - score 1
|
||||||
max: bandMax('Caution'), // top of Caution - score 0
|
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_GUST = { ideal: 15, limit: 25 }; // mph
|
||||||
const BAND_RAIN = { ideal: 0, limit: 25 }; // % chance
|
const BAND_RAIN = { ideal: 0, limit: 25 }; // % chance
|
||||||
const BAND_SOIL = { ideal: 0.10, limit: 0.25 }; // m3/m3 - dry ground to soggy
|
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));
|
return Math.max(0, Math.min(1, f));
|
||||||
}
|
}
|
||||||
|
|
||||||
function sunsoakScore(v) {
|
function plateauScore(v, pl) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
if (v >= SUNSOAK.flatLo && v <= SUNSOAK.flatHi) return 1;
|
if (v >= pl.flatLo && v <= pl.flatHi) return 1;
|
||||||
const f = v < SUNSOAK.flatLo
|
const f = v < pl.flatLo
|
||||||
? (v - SUNSOAK.min) / (SUNSOAK.flatLo - SUNSOAK.min)
|
? (v - pl.min) / (pl.flatLo - pl.min)
|
||||||
: (SUNSOAK.max - v) / (SUNSOAK.max - SUNSOAK.flatHi);
|
: (pl.max - v) / (pl.max - pl.flatHi);
|
||||||
return Math.max(0, Math.min(1, f));
|
return Math.max(0, Math.min(1, f));
|
||||||
}
|
}
|
||||||
|
|
||||||
function comfortableHour(r) {
|
// ── What "a good window" means for the active profile ────────────────────
|
||||||
if (!(r.elev > 0)) return false; // daylight only
|
// The panel used to answer one question - "would I enjoy standing outside"
|
||||||
if (r.utciAdj == null) return false;
|
// - whatever profile was selected, which made it useless to the very users
|
||||||
if (r.utciAdj < SUNSOAK.min || r.utciAdj > SUNSOAK.max) return false;
|
// who had told the app what they were doing. It now scores the SAME field the
|
||||||
if ((r.precipProb ?? 0) > BAND_RAIN.limit) return false;
|
// rest of the app is built around for that profile (the day tabs, the dial,
|
||||||
if ((r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) return false;
|
// 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
|
// 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
|
// 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
|
// 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.
|
// -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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// How good an hour is WITHIN the bands, 0-1. Weighted by how much each
|
// 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.
|
// measure actually decides whether the day was worth going out for.
|
||||||
function hourQuality(r) {
|
function hourQuality(r, crit) {
|
||||||
const parts = [
|
const parts = [
|
||||||
[sunsoakScore(r.utciAdj), 0.40],
|
[plateauScore(r[crit.field], crit.pl), crit.w.thermal],
|
||||||
[bandScore(r.precipProb ?? 0, BAND_RAIN), 0.25],
|
[bandScore(r.precipProb ?? 0, BAND_RAIN), crit.w.rain],
|
||||||
[bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), 0.20],
|
[bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), crit.w.gust],
|
||||||
[bandScore(r.soilM, BAND_SOIL), 0.15],
|
[bandScore(r.soilM, BAND_SOIL), crit.w.soil],
|
||||||
].filter(([v]) => v != null);
|
].filter(([v, w]) => v != null && w > 0);
|
||||||
const wsum = parts.reduce((s, [, w]) => s + 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;
|
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 [];
|
if (!weekDays || weekDays.length === 0) return [];
|
||||||
|
const crit = bestDayCriteria(profile, variant);
|
||||||
|
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
for (const day of weekDays.slice(0, 7)) {
|
for (const day of weekDays.slice(0, 7)) {
|
||||||
@@ -1088,7 +1143,7 @@ export function computeBestDay(weekDays, nowLocalISO) {
|
|||||||
|
|
||||||
let start = -1, len = 0, dayBest = null;
|
let start = -1, len = 0, dayBest = null;
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
if (comfortableHour(rows[i])) {
|
if (comfortableHour(rows[i], crit)) {
|
||||||
if (start < 0) start = i;
|
if (start < 0) start = i;
|
||||||
len++;
|
len++;
|
||||||
if (!dayBest || len > dayBest.len) dayBest = { start, len, end: i };
|
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
|
// temperate summer week half of them qualify dawn to dusk, and picking
|
||||||
// arbitrarily among those is a coin toss.
|
// arbitrarily among those is a coin toss.
|
||||||
const run = rows.slice(dayBest.start, dayBest.end + 1);
|
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({
|
candidates.push({
|
||||||
len: dayBest.len, quality, key: day.key,
|
len: dayBest.len, quality, key: day.key,
|
||||||
from: rows[dayBest.start], to: rows[dayBest.end],
|
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,
|
// 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.
|
// and "don't bother this week" is a real answer to the question being asked.
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return [{
|
return [{
|
||||||
icon: '🌧️',
|
icon: crit.field === 'utciAdj' ? '🌧️' : '🚫',
|
||||||
label: 'Next 7 days',
|
label: 'Next 7 days',
|
||||||
value: 'no settled outdoor windows',
|
value: EMPTY_VALUE[crit.field] ?? EMPTY_VALUE.utciAdj,
|
||||||
alert: false,
|
alert: false,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -1132,7 +1192,7 @@ export function computeBestDay(weekDays, nowLocalISO) {
|
|||||||
// window of perfect ones are both worth knowing about, and letting either
|
// window of perfect ones are both worth knowing about, and letting either
|
||||||
// half dominate hides one of them.
|
// half dominate hides one of them.
|
||||||
for (const c of candidates) {
|
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));
|
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
|
// 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".
|
// "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'
|
? 'all day'
|
||||||
: `${hh(c.from.iso)} – ${hh(endIso)}`;
|
: `${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
|
// Header line for the panel: names what is being judged when a variant makes
|
||||||
// that non-obvious, otherwise the plain framing.
|
// that non-obvious, otherwise the plain framing.
|
||||||
export function bestDaysLabel(profile, variant) {
|
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
|
return profile === 'outdoors' && variant
|
||||||
? `Best times for ${deriveProfileMain(profile, variant).mainLabel}`
|
? `Best times for ${mainLabel}`
|
||||||
: 'Best times out';
|
: (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) {
|
export function computeCropAdvice(weekDays, lat) {
|
||||||
|
|||||||
Reference in New Issue
Block a user