Correct 'Shade' temps for night
This commit is contained in:
fraxle
2026-08-02 17:38:59 +01:00
parent ff86b6d5ca
commit ffb5ed1d7d
8 changed files with 91 additions and 30 deletions
+7 -4
View File
@@ -379,9 +379,9 @@ export function UTCIForecast() {
// Columns that mark the start of a logical group - used to draw a faint
// vertical border separating groups in the forecast table.
const GROUP_ORDER = {
felt: ['utciP', 'vehicleT', 'indoorT', 'managedT', 'petHomeT', 'burn', 'utci', 'delta', 'tmrt'],
felt: ['utciP', 'shadeT', 'vehicleT', 'indoorT', 'managedT', 'petHomeT', 'burn', 'utci', 'delta', 'tmrt'],
surface: ['concreteT', 'soilT', 'soilT6', 'soilM', 'furSurfaceT', 'pawT'],
ambient: ['shadeT', 'petShadeT', 'air', 'rh', 'dew'],
ambient: ['petShadeT', 'air', 'rh', 'dew'],
precip: ['precip', 'precipProb', 'lightning'],
sky: ['cloud', 'vis'],
wind: ['wind', 'dir'],
@@ -1351,6 +1351,7 @@ export function UTCIForecast() {
</span>
</th>
${visibleCols.utciP && html`<th class=${`col-info-th ${groupStart('utciP')} ${groupColor('utciP')}`} scope="col" onClick=${(e) => handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>SunSoak <span class="col-unit">°C felt</span>${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ? html`<span class="col-env-badge">${UTCI_ENVIRONMENTS[utciEnv].shortLabel}</span>` : null}</th>`}
${visibleCols.shadeT && html`<th class=${`utci-tight-head col-info-th ${groupStart('shadeT')} ${groupColor('shadeT')}`} scope="col" onClick=${(e) => handleThClick('shadeT', e)} onMouseEnter=${(e) => handleThEnter('shadeT', e)} onMouseLeave=${handleThLeave}>Shade <span class="col-unit">°C felt</span></th>`}
${visibleCols.vehicleT && html`<th class=${`col-info-th ${groupStart('vehicleT')} ${groupColor('vehicleT')}`} scope="col" onClick=${(e) => handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle <span class="col-unit">°C peak</span></th>`}
${indoorMode === 'on' && !indoorManaged && html`<th class=${`col-info-th ${groupStart('indoorT')} ${groupColor('indoorT')}`} scope="col" onClick=${(e) => handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors <span class="col-unit">°C est.</span></th>`}
${indoorMode === 'on' && indoorManaged && html`<th class=${`col-info-th ${groupStart('managedT')} ${groupColor('managedT')}`} scope="col" onClick=${(e) => handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed <span class="col-unit">°C est.</span></th>`}
@@ -1365,7 +1366,6 @@ export function UTCIForecast() {
${visibleCols.soilT && html`<th class=${`col-info-th ${groupStart('soilT')} ${groupColor('soilT')}`} scope="col" onClick=${(e) => handleThClick('soilT', e)} onMouseEnter=${(e) => handleThEnter('soilT', e)} onMouseLeave=${handleThLeave}>Soil °C <span class="col-unit">surface</span></th>`}
${visibleCols.soilT6 && html`<th class=${`col-info-th ${groupStart('soilT6')} ${groupColor('soilT6')}`} scope="col" onClick=${(e) => handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm <span class="col-unit">°C root</span></th>`}
${visibleCols.soilM && html`<th class=${`col-info-th ${groupStart('soilM')} ${groupColor('soilM')}`} scope="col" onClick=${(e) => handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist <span class="col-unit">%</span></th>`}
${visibleCols.shadeT && html`<th class=${`utci-tight-head col-info-th ${groupStart('shadeT')} ${groupColor('shadeT')}`} scope="col" onClick=${(e) => handleThClick('shadeT', e)} onMouseEnter=${(e) => handleThEnter('shadeT', e)} onMouseLeave=${handleThLeave}>Shade <span class="col-unit">°C</span></th>`}
${visibleCols.petShadeT && html`<th class=${`utci-tight-head col-info-th ${groupStart('petShadeT')} ${groupColor('petShadeT')}`} scope="col" onClick=${(e) => handleThClick('petShadeT', e)} onMouseEnter=${(e) => handleThEnter('petShadeT', e)} onMouseLeave=${handleThLeave}>Pet Shade <span class="col-unit">°C</span></th>`}
${visibleCols.air && html`<th class=${`utci-tight-head col-info-th ${groupStart('air')} ${groupColor('air')}`} scope="col" onClick=${(e) => handleThClick('air', e)} onMouseEnter=${(e) => handleThEnter('air', e)} onMouseLeave=${handleThLeave}>Air <span class="col-unit">°C</span></th>`}
${visibleCols.rh && html`<th class=${`col-info-th ${groupStart('rh')} ${groupColor('rh')}`} scope="col" onClick=${(e) => handleThClick('rh', e)} onMouseEnter=${(e) => handleThEnter('rh', e)} onMouseLeave=${handleThLeave}>RH <span class="col-unit">%</span></th>`}
@@ -1521,6 +1521,10 @@ export function UTCIForecast() {
<td class=${groupStart('utciP')} style=${{ background: airTempBg(r.utciAdj, rPrev?.utciAdj, rNext?.utciAdj), color: airTempFontColor(), fontSize: '13px' }}>
${fmt(r.utciAdj)}${u('°C')}
</td>`}
${visibleCols.shadeT && html`
<td class=${groupStart('shadeT')} style=${{ color: airTempFontColor(), background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT) }}>
${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}
</td>`}
${visibleCols.vehicleT && html`
<td class=${groupStart('vehicleT')} style=${{ color: airTempFontColor(), background: airTempBg(r.vehicleT, rPrev?.vehicleT, rNext?.vehicleT) }}>
${fmt(r.vehicleT)}${u('°C')}
@@ -1571,7 +1575,6 @@ export function UTCIForecast() {
<td class=${groupStart('soilT6')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT6, rPrev?.soilT6, rNext?.soilT6) }}>${r.soilT6 != null ? fmt(r.soilT6) + u('°C') : '—'}</td>`}
${visibleCols.soilM && html`
<td class=${groupStart('soilM')} style=${(() => { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}</td>`}
${visibleCols.shadeT && html`<td class=${groupStart('shadeT')} style=${{ background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT), color: airTempFontColor() }}>${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}</td>`}
${visibleCols.petShadeT && html`
<td class=${groupStart('petShadeT')} title=${r.petShadeT != null ? (petCategory(r.petShadeT)?.label ?? '') : ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.petShadeT, rPrev?.petShadeT, rNext?.petShadeT) }}>
${r.petShadeT != null ? fmt(r.petShadeT) + u('°C') : '—'}
+20 -12
View File
@@ -14,7 +14,7 @@
import {
vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox,
calcConcreteTempPass, calcVehicleInteriorTempPass,
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp,
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
calcFurSurfaceTempPass,
} from './physics.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS } from './utils.js';
@@ -84,15 +84,22 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
const difEnv = dif * env.difFactor;
const globEnv = glob * env.globFactor;
// concreteT is now stamped in the two-pass section below (thermal lag).
// Shade air temp: the air you'd feel in this Solar Model's typical shade
// (building shadow, beach umbrella, canopy...). Shares SunSoak's TaEnv
// baseline and env-reduced radiation, so the two columns stay consistent.
const shadeT = calcShadeAirTemp(TaEnv, dirEnv + difEnv * 0.2, va, elev, env.shade);
// vehicleT is stamped in the two-pass section below (thermal lag).
const eh = vaporPressureHpa(Ta, RH);
const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev);
const utci = utciApprox(TaEnv, Tmrt, va, eh);
const utciAdj = utci + precipPenalty(precip, snow, va);
// Shade air temp: what a thermometer in this Solar Model's typical shade
// (building shadow, beach umbrella, canopy...) would read. Shares
// SunSoak's TaEnv baseline and env-reduced radiation, so the two stay
// consistent. Pet Shade reads this directly; the human Shade column
// below turns it into a felt temperature first.
const shadeAirT = calcShadeAirTemp(TaEnv, dirEnv + difEnv * 0.2, va, elev, env.shade);
// Shade (felt): SunSoak with the direct beam taken away — still outdoors,
// so the same wind, humidity and rain penalty apply. See
// calcShadeFeltTemp() for why this is a felt number and not the air temp.
const shadeT = calcShadeFeltTemp(shadeAirT, difEnv, va, eh, elev, env.shade)
+ precipPenalty(precip, snow, va);
// Derived
const compass = windCompass8(wd);
const { uvA, uvB } = uvSplit(uv, elev);
@@ -154,10 +161,11 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
uv, uvA, uvB,
precip, precipProb, lightning, cape, snow,
soilT0, soilT6, soilM, shadeT,
// Pet Shade reads the same hourly value as Shade but aggregates as a
// MEAN rather than a worst case, so it needs its own field to diverge
// from shadeT at 2h/3h/4h. Same for pawT vs concreteT below.
petShadeT: shadeT,
// Pet Shade stays on the shade AIR temperature: the human Shade column
// is a UTCI felt number on a human comfort scale, which says nothing
// useful about a cat. It also aggregates as a MEAN rather than a worst
// case, so it needs its own field either way (same for pawT/concreteT).
petShadeT: shadeAirT,
effectiveRad,
elev, Tmrt, utci, utciAdj, eh, compass,
visKm, aqi,
@@ -723,7 +731,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
// ── Pets (cats / small dogs) ────────────────────────────────────────────
if (profile === 'pets') {
const peakFur = peakRow('furSurfaceT');
const peakShade = peakRow('shadeT');
const peakShade = peakRow('petShadeT');
const peakIndoor = peakRow('indoorT');
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
@@ -740,8 +748,8 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
...(show('petShadeT') ? [{
icon: '🌳',
label: 'Shade for pet',
value: peakShade ? `${Math.round(peakShade.shadeT)}° at ${hhmm(peakShade.iso)}` : '-',
alert: !!(peakShade && peakShade.shadeT >= 26),
value: peakShade ? `${Math.round(peakShade.petShadeT)}° at ${hhmm(peakShade.iso)}` : '-',
alert: !!(peakShade && peakShade.petShadeT >= 26),
grp: 'ambient',
}] : []),
...(show('petHomeT') ? [{
+8 -5
View File
@@ -143,8 +143,9 @@ export const POLLEN_TYPES = {
// taOffset - offset applied to air temperature in degrees C (evapotranspiration etc.)
// label - short display name
// note - shown in column header tooltip to flag modified calculation
// shade - per-environment microclimate inputs for the Shade air-temp
// column (see calcShadeAirTemp in physics.js). The env air-temp
// shade - per-environment microclimate inputs for the Shade column
// (calcShadeAirTemp + calcShadeFeltTemp in physics.js, where
// shelter doubles as the sky-view proxy). The env air-temp
// shift is NOT here - it comes from taOffset via the shared
// TaEnv baseline. This block only carries local sun-trap effects:
// shelter 0-1 wind shelter (high = enclosed, calm pocket)
@@ -195,7 +196,9 @@ export function deriveProfileMain(activeProfile, outdoorsVariant) {
const isPetsProfile = activeProfile === 'pets';
const mainConfigKey = isHomeOrOffice ? 'indoor' : isVehicleProfile ? 'vehicle' : isPetsProfile ? 'fur' : 'solar';
const mainField = isHomeOrOffice ? 'indoorT' : isVehicleProfile ? 'vehicleT' : isPetsProfile ? 'furSurfaceT' : 'utciAdj';
const secondaryField = isHomeOrOffice ? 'managedT' : isPetsProfile ? 'shadeT' : isVehicleProfile ? null : 'shadeT';
// Pets pairs Fur Temp with PET Shade: the human Shade column is now a UTCI
// felt number on a human comfort scale, which says nothing about a cat.
const secondaryField = isHomeOrOffice ? 'managedT' : isPetsProfile ? 'petShadeT' : isVehicleProfile ? null : 'shadeT';
const mainLabel = activeProfile === 'outdoors'
? (OUTDOORS_VARIANTS[outdoorsVariant]?.name || FILTER_PROFILES.outdoors.label)
: (FILTER_PROFILES[activeProfile]?.label || 'SunSoak');
@@ -260,7 +263,7 @@ export const workVariantKeys = ['farming', 'construction', 'market', 'windowclea
export const COL_DESCRIPTIONS = {
hour: { title: 'Rows', desc: 'Local wall-clock time for this forecast row. Each row covers the interval set by the stepper in this header, clock-aligned from midnight — at 3h, a row labelled 09:00 spans 09:0011:59. Totals like rainfall are summed across the block, risk figures show the block\'s peak, and smooth readings like air temperature are averaged.' },
air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' },
shadeT: { title: 'Shade', desc: 'Estimated air temperature sitting in the typical shade of the selected Solar Model — building shadow, beach umbrella, tree canopy, boat panels, and so on. Sheltered, sun-warmed surroundings push it above the official open-ground Air figure on calm sunny hours; wind and cloud pull it back toward it. This is an estimate, not a measurement, and differs from the standardised 2 m Air reading.' },
shadeT: { title: 'Shade', desc: 'SunSoak with the sun taken away: the felt temperature standing in the typical shade of the selected Solar Model — building shadow, beach umbrella, tree canopy, boat panels, and so on. You are still outdoors, so wind, humidity and rain hit you exactly as they do in the open; only the direct beam and part of the sky are blocked. Expect several degrees below SunSoak in daytime sun, and near-identical readings after dark, when there is no sun to stand out of. An estimate of a typical shaded spot, not a measurement.' },
rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.' },
dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.' },
wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m in mph, with peak gust in brackets where significantly higher. Gust colour indicates Beaufort scale severity: amber = Near Gale (32+ mph), orange = Gale (39+ mph), red = Severe Gale or above (55+ mph). Banners above the table classify conditions from Strong Breeze through to Violent Storm using standard wind categories. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.' },
@@ -291,7 +294,7 @@ export const COL_DESCRIPTIONS = {
pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 1050; High = 50200; Very High = 200+. Values vary by species and season.' },
furSurfaceT: { title: 'Fur Temp', desc: 'Estimated fur surface temperature a cat or small dog experiences in direct sun, calibrated for a thin/short coat rather than a heavily-insulated breed. Runs hotter than air temperature on sunny, still days — set fur colour with the selector alongside this column.' },
pawT: { title: 'Paw Temp', desc: 'Estimated ground/pavement surface temperature for paw contact, colour-coded by burn risk: green below 40 °C (safe), amber 4052 °C (discomfort / possible burn on prolonged contact), red above 52 °C (can burn within 60 seconds). If you can\'t hold the back of your hand on the surface for 7 seconds, it\'s too hot to walk your pet.' },
petShadeT: { title: 'Pet Shade', desc: 'Shaded air temperature for a resting pet, using the same shade estimate as the Shade column but with a lower, pet-specific alert threshold — small pets have far less capacity to dump heat than humans.' },
petShadeT: { title: 'Pet Shade', desc: 'Shaded air temperature for a resting pet, using the same shade microclimate model as the Shade column but kept as an air temperature — a human felt-temperature index says little about an animal — and with a lower, pet-specific alert threshold — small pets have far less capacity to dump heat than humans.' },
petHomeT: { title: 'Pet Home', desc: 'Indoor temperature estimate for a pet left home alone, using the same model as the Indoors column but with a lower, pet-specific alert threshold (26 °C) rather than the human heatwave guidance threshold.' },
};
+1 -1
View File
@@ -55,7 +55,7 @@ const COLS = [
['Tmrt', '°C', 'right', 7, '0.0'],
// Air (4)
['Air Temp', '°C', 'right', 8, '0.0'],
['Shade Air', '°C', 'right', 9, '0.0'],
['Shade Felt', '°C', 'right', 9, '0.0'],
['Dew Point', '°C', 'right', 9, '0.0'],
['Humidity', '%', 'right', 8, '0' ],
// UV & Solar (7)
+49 -2
View File
@@ -14,6 +14,7 @@
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated, speedMph, windMps) equilibrium cabin temp
// calcVehicleInteriorTempPass(TaArr, globArr, elevArr, vaArr, vehicleType, ventilated, speedMph) lagged cabin temp
// calcShadeAirTemp(TaEnv, effRadEnv, va, elev, shade) per-env shade air temp
// calcShadeFeltTemp(TaShade, difEnv, va, eh, elev, shade) felt temp in that shade
// vaporPressureHpa(Ta, RH) Magnus formula - hPa
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position (-)
// calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temperature
@@ -601,9 +602,55 @@ export function calcShadeAirTemp(Ta, effectiveRad, va, elev, shade) {
const daytime = (elev != null && elev > 0) ? 1 : 0;
const sunTerm = SHADE_K_SUN * (shade.sun ?? 0) * rad * daytime;
const windTerm = SHADE_K_WIND * (1 - (shade.shelter ?? 0)) * Math.max(0, va || 0);
const adj = sunTerm - windTerm;
// Wind can only cancel the sun-trap warming, never push the pocket BELOW
// ambient: mixing drags trapped air back toward the open-air reading, it
// does not manufacture cold. Without the floor a breezy night read a
// degree under Air for no physical reason (and Pet Shade with it).
// Genuinely cool environments still sit below Air via env.taOffset.
const adj = Math.max(0, sunTerm - windTerm);
// Local sun-trap nudge only - the env air-temp shift is already in Ta.
return Ta + Math.max(-4, Math.min(5, adj));
return Ta + Math.min(5, adj);
}
// -------------------------------------------------------------------
// SHADE FELT TEMPERATURE - "SunSoak, but out of the sun"
// -------------------------------------------------------------------
// calcShadeAirTemp() above answers "what would a thermometer in the shade
// read". That is NOT what someone standing in the shade feels: they are
// still outdoors, so wind still strips heat off them and humidity still
// blunts their sweating, exactly as in the open. Reading a shade AIR temp
// beside SunSoak's FELT temp made Shade look hotter than full sun on a
// breezy night - both numbers were right, but they were different
// quantities sitting in adjacent columns.
//
// So Shade runs the same UTCI machinery as SunSoak, changing one input:
// the radiant load. Out of the beam a person sees no direct sun, only the
// part of the sky their shade does not block, so:
//
// direct -> 0 (that is what shade IS)
// diffuse -> difEnv * skyView
// global -> the same diffuse (no beam reaching the ground to reflect)
//
// skyView derives from shade.shelter, reusing it as an enclosure proxy: a
// forest canopy or building canyon that stops the wind also hides most of
// the sky dome, while an umbrella on open sand blocks little besides the
// sun itself. It is a proxy, not a measurement - like the rest of the
// shade block, this is an estimate of a typical spot in that environment.
//
// Consequences worth knowing: at night there is no beam to remove, so
// Shade converges on SunSoak (they differ only by the sun-trap nudge in
// the air temp). In daytime sun Shade sits several degrees under SunSoak,
// which is the whole point of standing in it. On a windy hour BOTH fall
// together, because both now carry the same wind term.
// -------------------------------------------------------------------
export const SHADE_SKY_BLOCK = 0.6; // fraction of the sky an enclosed shade hides, per unit shelter
export function calcShadeFeltTemp(TaShade, difRad, va, eh, elev, shade) {
if (TaShade == null) return null;
const skyView = 1 - SHADE_SKY_BLOCK * (shade?.shelter ?? 0);
const difShade = Math.max(0, (difRad || 0) * skyView);
const Tmrt = calcTmrt(TaShade, 0, difShade, difShade, elev);
return utciApprox(TaShade, Tmrt, va, eh);
}
// -- Pets Profile - Fur Surface Temperature -----------------------------------