5.0.3
The Week Ahead - Best Times Out
This commit is contained in:
+151
-47
@@ -17,7 +17,7 @@ import {
|
||||
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
|
||||
calcFurSurfaceTempPass,
|
||||
} from './physics.js';
|
||||
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS } from './utils.js';
|
||||
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_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 }) {
|
||||
@@ -959,13 +959,20 @@ function bestRun(arr, pred) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// computeBestDay(weekDays, profile, variant, nowLocalISO) - the day with the
|
||||
// longest unbroken run of pleasant outdoor hours, for "The Week Ahead".
|
||||
// computeBestDay(weekDays, nowLocalISO) - every day in the
|
||||
// next week with a decent unbroken run of pleasant outdoor hours, for
|
||||
// "The Week Ahead".
|
||||
//
|
||||
// The app already works out comfort windows for the selected day, but
|
||||
// comparing days meant tapping through all fourteen tabs. This answers the
|
||||
// question the day tabs make you do by hand.
|
||||
//
|
||||
// Naming a single winner was too thin to plan around: knowing Saturday is
|
||||
// the best day says nothing about whether Sunday is also fine, or whether
|
||||
// the whole week is a washout. So it lists the days that qualify, in date
|
||||
// order, each with a 0-100 score so they can be compared at a glance
|
||||
// without reordering them out of the order you plan in.
|
||||
//
|
||||
// Deliberately NOT part of computeGlanceSummary: "At a glance" describes the
|
||||
// one selected day, and a week-scoped line reads as a category error inside
|
||||
// it. It renders in its own panel below the rail instead.
|
||||
@@ -980,16 +987,79 @@ function bestRun(arr, pred) {
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for
|
||||
const IDEAL_UTCI = 20; // centre of the "no thermal stress" band
|
||||
const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist
|
||||
|
||||
function comfortableHour(r) {
|
||||
return r.elev > 0 // daylight
|
||||
&& r.utciAdj != null && r.utciAdj >= 9 && r.utciAdj <= 28 // no thermal stress
|
||||
&& (r.precipProb ?? 0) < 50 // unlikely to rain on you
|
||||
&& (r.gust ?? r.va ?? 0) * 2.237 < 32; // below near-gale
|
||||
// ── 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
|
||||
// day-out hour at all. That split is what lets the panel say "Thursday is
|
||||
// fine, Saturday is better" instead of only ever answering yes or no.
|
||||
//
|
||||
// SunSoak is the exception: rather than one ideal point it has a flat top
|
||||
// spanning the Comfortable and Warm thermal-stress bands - anywhere in there
|
||||
// is a good day out and splitting hairs between 20 and 25 would be false
|
||||
// precision - then falls 100% to 0% across the Cool band below and the
|
||||
// Caution band above. Past those it isn't a day-out hour at all.
|
||||
//
|
||||
// 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 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
|
||||
|
||||
// 1 at the ideal end, 0 at the limit, clamped. Direction is taken from the
|
||||
// two numbers, so it reads the same whether lower or higher is better.
|
||||
function bandScore(v, { ideal, limit }) {
|
||||
if (v == null) return null; // unknown - caller decides
|
||||
const f = (limit - v) / (limit - ideal);
|
||||
return Math.max(0, Math.min(1, f));
|
||||
}
|
||||
|
||||
export function computeBestDay(weekDays, profile, variant, nowLocalISO) {
|
||||
function sunsoakScore(v) {
|
||||
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);
|
||||
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;
|
||||
// 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;
|
||||
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) {
|
||||
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);
|
||||
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) {
|
||||
if (!weekDays || weekDays.length === 0) return [];
|
||||
|
||||
const candidates = [];
|
||||
@@ -1012,59 +1082,93 @@ export function computeBestDay(weekDays, profile, variant, nowLocalISO) {
|
||||
}
|
||||
if (!dayBest || dayBest.len < PICK_MIN_HOURS) continue;
|
||||
|
||||
// How far the run sits from an ideal ~20 °C, averaged. Length alone can't
|
||||
// separate days: in a temperate summer week half of them run comfortable
|
||||
// from dawn to dusk, and picking arbitrarily among those is a coin toss.
|
||||
const run = rows.slice(dayBest.start, dayBest.end + 1);
|
||||
const miss = run.reduce((s, r) => s + Math.abs(r.utciAdj - IDEAL_UTCI), 0) / run.length;
|
||||
// Average quality across the run. Length alone can't separate days: in a
|
||||
// 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;
|
||||
|
||||
candidates.push({
|
||||
len: dayBest.len, miss, key: day.key,
|
||||
len: dayBest.len, quality, key: day.key,
|
||||
from: rows[dayBest.start], to: rows[dayBest.end],
|
||||
daylight: rows.filter(r => r.elev > 0).length,
|
||||
});
|
||||
}
|
||||
if (candidates.length === 0) return [];
|
||||
// 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: '🌧️',
|
||||
label: 'Next 7 days',
|
||||
value: 'no settled outdoor windows',
|
||||
alert: false,
|
||||
}];
|
||||
}
|
||||
|
||||
// Longest run wins, but anything within an hour of the longest counts as a
|
||||
// tie and is settled on which day is actually the most pleasant.
|
||||
const maxLen = Math.max(...candidates.map(c => c.len));
|
||||
const best = candidates
|
||||
.filter(c => c.len >= maxLen - 1)
|
||||
.sort((a, b) => a.miss - b.miss)[0];
|
||||
// Score each day out of 100 against an ideal day out rather than against
|
||||
// the rest of the week: a middling week should read as middling, not have
|
||||
// its least-bad day flattered up to full marks.
|
||||
//
|
||||
// how much of the daylight is usable (50%)
|
||||
// where in the bands those hours actually sit (50%)
|
||||
//
|
||||
// Even between the two: a long window of merely tolerable hours and a short
|
||||
// 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;
|
||||
c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality));
|
||||
}
|
||||
|
||||
// Only the top few are kept: past four rows the panel stops being a
|
||||
// shortlist and turns back into the day tabs.
|
||||
const shown = [...candidates]
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, PICK_MAX_DAYS)
|
||||
.sort((a, b) => (a.key < b.key ? -1 : 1));
|
||||
|
||||
const hh = (iso) => {
|
||||
const h = parseInt(iso.slice(11, 13), 10);
|
||||
return `${h % 12 || 12}${h < 12 ? 'am' : 'pm'}`;
|
||||
};
|
||||
// Dates are keyed 'YYYY-MM-DD' in local wall-clock terms, so read them back
|
||||
// as UTC to stop the browser's own zone shifting the weekday.
|
||||
const dayName = new Date(`${best.key}T00:00:00Z`)
|
||||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' });
|
||||
|
||||
// The panel title already says "The Week Ahead", so the row just names what
|
||||
// is being judged rather than repeating the timeframe.
|
||||
const label = profile === 'outdoors' && variant
|
||||
? `Best for ${deriveProfileMain(profile, variant).mainLabel}`
|
||||
: 'Best day out';
|
||||
return shown.map(c => {
|
||||
// Dates are keyed 'YYYY-MM-DD' in local wall-clock terms, so read them back
|
||||
// as UTC to stop the browser's own zone shifting the weekday.
|
||||
const dayName = new Date(`${c.key}T00:00:00Z`)
|
||||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' });
|
||||
|
||||
// The end row is the last comfortable hour, so the window runs to the end
|
||||
// of it - 9am-12pm means 9:00 up to 12:59.
|
||||
const endIso = `${best.to.iso.slice(0, 11)}${String((parseInt(best.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`;
|
||||
// The end row is the last comfortable hour, so the window runs to the end
|
||||
// of it - 9am-12pm means 9:00 up to 12:59.
|
||||
const endIso = `${c.to.iso.slice(0, 11)}${String((parseInt(c.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`;
|
||||
|
||||
// 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 = best.daylight > 0 && best.len >= best.daylight - 1
|
||||
? 'comfortable all day'
|
||||
: `${hh(best.from.iso)} – ${hh(endIso)}`;
|
||||
// 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
|
||||
? 'comfortable all day'
|
||||
: `${hh(c.from.iso)} – ${hh(endIso)}`;
|
||||
|
||||
return [{
|
||||
icon: '📅',
|
||||
label,
|
||||
value: `${dayName} · ${window}`,
|
||||
alert: false,
|
||||
grp: 'felt',
|
||||
}];
|
||||
return {
|
||||
icon: '📅',
|
||||
label: dayName,
|
||||
value: window,
|
||||
// Rows stay in date order - the order you plan in - so the ranking is
|
||||
// carried by the score bar instead of by position.
|
||||
score: c.score,
|
||||
alert: false,
|
||||
// No grp: the group tints categorise the glance rail's mixed subjects,
|
||||
// but here every row is the same kind of thing, so a colour would be
|
||||
// decoration. Plain rows with hairline dividers instead.
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return profile === 'outdoors' && variant
|
||||
? `Best times for ${deriveProfileMain(profile, variant).mainLabel}`
|
||||
: 'Best times out';
|
||||
}
|
||||
|
||||
export function computeCropAdvice(weekDays, lat) {
|
||||
|
||||
Reference in New Issue
Block a user