Fix and enhance the concrete calculation
Minify the about.css file
This commit is contained in:
fraxle
2026-05-19 10:51:39 +01:00
parent ab9cccd38d
commit ffacb81ab2
7 changed files with 239 additions and 42 deletions
+3 -2
View File
@@ -53,10 +53,11 @@
margin-top: 14px;
}
/* "↳ Pangbourne, Berkshire" */
/* "↳ Location, Somewhere" */
.utci-current-loc {
font-family: Fraunces, serif;
font-style: italic;
font-weight: 800;
font-size: 18px;
margin-top: 12px;
color: #1e1208;
@@ -70,7 +71,7 @@
color: #b09870;
display: block;
margin-top: 4px;
margin-left: 18px;
margin-left: 15px;
letter-spacing: 0.08em;
}
+36 -16
View File
@@ -12,7 +12,7 @@
import {
vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox,
calcConcreteTemp, calcVehicleInteriorTemp,
calcConcreteTempPass, calcVehicleInteriorTemp,
calcIndoorTempPass, calcManagedIndoorTempPass,
} from './physics.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty } from './utils.js';
@@ -55,20 +55,23 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null;
const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null;
const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null;
// iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z).
// For display we slice the string directly - no Date object needed.
// For solarElevationDeg (which uses .getUTC* internally) we need the
// true UTC instant: treat the local time as UTC then subtract the offset.
// e.g. Brisbane UTC+10: local 14:00 - parse as UTC 14:00 - subtract 10h - UTC 04:00 -
// iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z).
// For solarElevationDeg (which uses .getUTC* internally) we need the
// true UTC instant: treat the local time as UTC then subtract the offset.
const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
const elev = solarElevationDeg(location.lat, location.lon, dt);
// Use direct_radiation (beam sunlight) + a fraction of diffuse for concrete.
// direct_radiation is zero on fully overcast days - far more accurate than
// shortwave_radiation which can be unreliably high even at 100% cloud cover.
// Diffuse (scattered light through cloud) contributes ~20% as much heat to
// a surface as direct beam, so we weight it accordingly.
const effectiveRad = dir + dif * 0.2;
const concreteT = calcConcreteTemp(Ta, effectiveRad, va);
// iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z).
// For display we slice the string directly - no Date object needed.
// For solarElevationDeg (which uses .getUTC* internally) we need the
// true UTC instant: treat the local time as UTC then subtract the offset.
// e.g. Brisbane UTC+10: local 14:00 - parse as UTC 14:00 - subtract 10h - UTC 04:00 -
const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
const elev = solarElevationDeg(location.lat, location.lon, dt);
// concreteT is now stamped in the two-pass section below (thermal lag).
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent);
const eh = vaporPressureHpa(Ta, RH);
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
@@ -132,20 +135,37 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
cc, ccLow, ccMid, ccHigh, cloudCat,
uv, uvA, uvB,
precip, snow,
soilT0, soilT6, soilM, concreteT, vehicleT,
soilT0, soilT6, soilM, vehicleT,
effectiveRad,
elev, Tmrt, utci, utciAdj, eh, compass,
visKm, aqi,
grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen,
};
}) : [];
// Two-pass indoor temperature: needs the full hourly arrays so thermal
// lag can look back at previous hours. Run after hourlyRows is built,
// then stamp each row with its indoorT value.
// Two-pass calculations: concrete thermal lag + indoor temperature.
// Both need the full hourly arrays so they can look back at previous
// hours. Run after hourlyRows is built, then stamp each row.
if (hourlyRows.length > 0) {
const TaArr = hourlyRows.map(r => r.Ta);
const globArr = hourlyRows.map(r => r.glob);
const elevArr = hourlyRows.map(r => r.elev);
const TaArr = hourlyRows.map(r => r.Ta);
const globArr = hourlyRows.map(r => r.glob);
const elevArr = hourlyRows.map(r => r.elev);
const radArr = hourlyRows.map(r => r.effectiveRad);
const vaArr = hourlyRows.map(r => r.va);
const uvArr = hourlyRows.map(r => r.uv);
const cloudCatArr = hourlyRows.map(r => r.cloudCat);
const soilMArr = hourlyRows.map(r => r.soilM);
const precipArr = hourlyRows.map(r => r.precip);
const snowArr = hourlyRows.map(r => r.snow);
// Concrete surface temperature with thermal lag (1.5 h time constant).
// A slab baking in the sun retains heat when cloud rolls in, and takes
// a couple of hours of sunshine to fully heat up from a cold start.
const concreteTemps = calcConcreteTempPass(
TaArr, radArr, vaArr, elevArr, uvArr, cloudCatArr, soilMArr, precipArr, snowArr
);
hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; });
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType);
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType);
hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; });
+181 -16
View File
@@ -6,7 +6,9 @@
//
// Exports (in order of appearance):
// SIGMA, EPSILON_P, A_K, ALBEDO_GRASS, ALBEDO_CONCRETE (radiation constants)
// calcConcreteTemp(Ta, globalRad, windSpeed) urban surface temp
// LAG_HOURS_CONCRETE slab thermal time constant
// calcConcreteTemp(Ta, globalRad, windSpeed, ...) urban surface temp (instantaneous target)
// calcConcreteTempPass(arrays...) thermal-lag pass over full hourly arrays
// calcIndoorTempPass(TaArr, globArr, elevArr, buildingType) passive indoor temp
// calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType) managed indoor temp
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated)
@@ -42,26 +44,189 @@ export const ALBEDO_CONCRETE = 0.30;
// CONCRETE SURFACE TEMPERATURE (Urban profile)
// -------------------------------------------------------------------
// Estimates the surface temperature of exposed concrete using a
// simplified energy-balance approach:
// - Absorbed solar = globalRad - (1 - albedo)
// - No latent heat (no evaporation - concrete is dry)
// - Convective loss to air proportional to wind speed
// - Result is clamped to a physically plausible range
// multi-factor energy-balance approach. Six physical effects are
// modelled beyond the basic solar-gain / convective-loss pair:
//
// This is what matters for contact heat stress in cities - the UTCI
// standard uses grass, which runs ~5-15 -C cooler than urban concrete
// on a sunny day because grass sweats (transpires).
// 1. SUN ANGLE CORRECTION
// At low solar elevation the sun hits the surface obliquely,
// spreading energy over a larger area. A sin(elev) factor
// reduces absorbed solar proportionally. Clamped at a 5-deg
// minimum so the result stays finite near the horizon.
//
// 2. CLOUD TYPE TRANSMITTANCE
// Cloud cover category drives a transmittance multiplier.
// Low cloud (stratus) is far more opaque than high cirrus:
// clear 1.00 - full beam reaches the surface
// wispy 0.92 - cirrus barely attenuates
// scattered 0.72 - broken cumulus, significant blocking
// overcast 0.28 - thick stratus, mostly diffuse remains
// The raw effectiveRad already dims with cloud cover from the
// API, but this adds the qualitative distinction between cloud
// types that the single radiation number does not capture.
//
// 3. UV CLARITY FACTOR
// UV index is a proxy for atmospheric clarity beyond cloud cover -
// aerosols, haze, and humidity all reduce it. A UV of 8+ indicates
// a very clean, dry atmosphere with maximum direct-beam intensity.
// Normalised to a 0.85-1.00 range so it modulates rather than
// dominates. No UV data defaults to neutral (1.0).
//
// 4. EVAPORATIVE COOLING FROM SOIL MOISTURE
// Wet concrete loses heat via evaporation. Soil moisture at 0-1cm
// is used as a proxy for surface wetness (0 = bone dry, 1 = fully
// saturated). A saturated surface loses up to ~8-C relative to
// the dry case - consistent with published wet-pavement studies.
//
// 5. RAIN-WET SURFACE
// Active precipitation forces surface wetness regardless of soil
// moisture data. Above 0.5 mm/h the surface is considered fully
// wet and the maximum evaporative penalty applies.
//
// 6. SNOW COVER
// Snow on concrete insulates the slab from solar gain AND strongly
// reflects incoming radiation (albedo ~0.80 for fresh snow vs 0.30
// for bare concrete). When snowfall is active or lying snow is
// implied (snow > 0), absorbed radiation is cut by 85% and a small
// insulating offset is applied instead.
//
// Colour thresholds (same as before - surface contact risk):
// < Ta - should not occur (clamped)
// Ta - 45 -C - warm but bearable contact
// 45 - 60 -C - pain threshold for bare skin contact
// > 60 -C - burns on contact (relevant for paws / bare feet)
// -------------------------------------------------------------------
export function calcConcreteTemp(Ta, globalRad, windSpeed) {
export function calcConcreteTemp(Ta, globalRad, windSpeed, solElev, uv, cloudCat, soilM, precip, snow) {
if (globalRad == null || Ta == null) return null;
const absorbed = globalRad * (1 - ALBEDO_CONCRETE); // W/m-
// Convective heat transfer coefficient: ~10 W/m-K still air, rises with wind
// (10 reflects realistic natural convection; 5 was too low and ran too hot)
// -- 6. Snow short-circuit --------------------------------------------
// Snow-covered concrete behaves like a white reflective insulator.
// Absorbed solar collapses; slab temp stays close to air temp.
if (snow != null && snow > 0) {
const snowAbsorbed = globalRad * (1 - 0.80); // fresh snow albedo ~0.80
const hcSnow = 10 + 4.5 * Math.sqrt(Math.max(windSpeed || 0, 0));
const Ts = Ta + snowAbsorbed / hcSnow;
return Math.max(Ta - 1, Math.min(Ts, 40)); // snow-covered slab rarely exceeds 40-C
}
// -- 1. Sun angle correction ------------------------------------------
// Low-angle sun spreads energy across a larger surface area.
// sin(elev) = 1.0 at 90-deg (overhead), ~0.17 at 10-deg (grazing).
// Default to sin(45-deg) ~0.71 when elevation is unknown.
const elevDeg = (solElev != null) ? Math.max(5, solElev) : 45;
const angleCorrection = Math.sin(elevDeg * Math.PI / 180);
// -- 2. Cloud type transmittance --------------------------------------
// Modulates beam quality beyond what raw radiation already captures.
const cloudTransmit = cloudCat === 'clear' ? 1.00
: cloudCat === 'wispy' ? 0.92
: cloudCat === 'scattered' ? 0.72
: /* overcast */ 0.28;
// -- 3. UV clarity factor --------------------------------------------
// UV index as atmospheric-clarity proxy. Scaled to 0.85-1.00 range.
// uv=0 (night or heavy cloud) - neutral 1.0 (no adjustment needed,
// radiation already near-zero). uv=8+ - max clarity bonus of 1.0.
const uvClarity = (uv && uv > 0)
? 0.85 + 0.15 * Math.min(1, uv / 8)
: 1.0;
// -- Absorbed solar with all modifiers --------------------------------
const absorbed = globalRad * (1 - ALBEDO_CONCRETE)
* angleCorrection
* cloudTransmit
* uvClarity;
// -- Convective loss --------------------------------------------------
const hc = 10 + 4.5 * Math.sqrt(Math.max(windSpeed || 0, 0));
// Surface temp: Ta + solar gain / convective loss
// -- Dry surface temperature ------------------------------------------
const Ts = Ta + absorbed / hc;
// Clamp: can't be cooler than air, cap at 85 -C (melting asphalt territory)
return Math.max(Ta, Math.min(Ts, 85));
// -- 4 + 5. Evaporative cooling ---------------------------------------
// Rain-wet surface overrides soil moisture - surface is fully saturated.
const surfaceWet = (precip != null && precip >= 0.5)
? 1.0
: Math.max(0, Math.min(1, soilM ?? 0));
// Max evaporative delta ~8-C at full saturation (published wet-pavement data).
const evapCooling = surfaceWet * 8;
const TsCooled = Ts - evapCooling;
// -- Final clamp: no cooler than air, no hotter than 85-C -------------
return Math.max(Ta, Math.min(TsCooled, 85));
}
// -------------------------------------------------------------------
// CONCRETE THERMAL LAG TIME CONSTANT
// -------------------------------------------------------------------
// A standard urban pavement slab (~100 mm thick, exposed top surface,
// air below via sub-base) has moderate thermal mass. Real-world
// measurement studies put the e-folding time constant at 1.5-2 h for
// this geometry - we use 1.5 h as representative of the thin end of
// typical footway construction (block paving, tarmac-over-hardcore).
//
// If this ever needs to vary by surface type, pull it into a
// SURFACE_TYPES preset object mirroring BUILDING_TYPES in utils.js.
// For now a single named constant keeps the intent obvious.
// -------------------------------------------------------------------
export const LAG_HOURS_CONCRETE = 1.5;
// -------------------------------------------------------------------
// CONCRETE SURFACE TEMPERATURE - THERMAL LAG PASS
// -------------------------------------------------------------------
// Wraps calcConcreteTemp in the same exponential-blending pattern used
// by calcIndoorTempPass. Instead of snapping to the instantaneous
// target each hour, the slab temperature blends toward it at a rate
// controlled by LAG_HOURS_CONCRETE.
//
// Effect in practice:
// - A slab baking at 55-C when cloud rolls in will still read ~48-C
// an hour later and ~44-C two hours later - not instantly 22-C.
// - A cold slab at dawn takes 2-3 hours of strong sun to fully heat.
// - Post-rain cool-down persists into the next hour even if it stops.
//
// Call AFTER building hourlyRows (same pattern as calcIndoorTempPass).
// Returns a Float64-like plain Array of concrete temps, one per hour.
//
// Arguments are parallel arrays (one value per forecast hour):
// TaArr - air temperature (-C)
// radArr - effectiveRad (dir + dif*0.2) (W/m-)
// vaArr - wind speed (m/s)
// elevArr - solar elevation (degrees)
// uvArr - UV index
// cloudCatArr - cloud category string
// soilMArr - soil moisture 0-1cm (0-1 fraction)
// precipArr - precipitation (mm/h)
// snowArr - snowfall (cm/h)
// -------------------------------------------------------------------
export function calcConcreteTempPass(TaArr, radArr, vaArr, elevArr, uvArr, cloudCatArr, soilMArr, precipArr, snowArr) {
const n = TaArr.length;
const result = new Array(n);
const alpha = 1 - Math.exp(-1 / LAG_HOURS_CONCRETE);
// Seed from the first hours instantaneous value so we start somewhere
// physically reasonable rather than zero.
let Tc = calcConcreteTemp(
TaArr[0], radArr[0], vaArr[0],
elevArr[0], uvArr[0], cloudCatArr[0],
soilMArr[0], precipArr[0], snowArr[0]
) ?? TaArr[0];
for (let i = 0; i < n; i++) {
const target = calcConcreteTemp(
TaArr[i], radArr[i], vaArr[i],
elevArr[i], uvArr[i], cloudCatArr[i],
soilMArr[i], precipArr[i], snowArr[i]
) ?? TaArr[i];
// Blend slab temp toward this hours target.
Tc = Tc + alpha * (target - Tc);
// Slab cannot be cooler than air (no active cooling mechanism).
result[i] = Math.max(TaArr[i], Tc);
}
return result;
}
// -------------------------------------------------------------------