955 lines
49 KiB
JavaScript
955 lines
49 KiB
JavaScript
// ------------------------------------------------------------------------
|
|
// physics.js - Physical constants and meteorological calculations.
|
|
//
|
|
// All numbers are peer-reviewed constants or coefficients. Nothing in
|
|
// here should need editing unless the underlying science changes.
|
|
//
|
|
// Exports (in order of appearance):
|
|
// SIGMA, EPSILON_P, A_K, ALBEDO_GRASS, ALBEDO_CONCRETE (radiation constants)
|
|
// 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, 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
|
|
// utciApprox(Ta, Tmrt, va10, ehPa) Br-de et al. 2012 UTCI polynomial
|
|
// ------------------------------------------------------------------------
|
|
|
|
import { VEHICLE_TYPES, BUILDING_TYPES } from './utils.js';
|
|
|
|
// -------------------------------------------------------------------
|
|
// PHYSICAL CONSTANTS
|
|
// -------------------------------------------------------------------
|
|
// These are real-world physics values - don't change them unless you
|
|
// have a peer-reviewed reason. They're used by calcTmrt() below to
|
|
// work out how much heat your skin actually absorbs from the sun.
|
|
// SIGMA - Stefan-Boltzmann constant (radiates heat)
|
|
// EPSILON_P - emissivity of human skin (~0.97)
|
|
// A_K - short-wave absorption coefficient for clothing
|
|
// ALBEDO_GRASS - how much sun grass reflects back at you (23%)
|
|
// ALBEDO_CONCRETE - how much sun concrete reflects back (30%)
|
|
// Concrete absorbs more net solar than grass and has
|
|
// no evaporative cooling, so its surface runs hot.
|
|
// -------------------------------------------------------------------
|
|
export const SIGMA = 5.670374419e-8;
|
|
export const EPSILON_P = 0.97;
|
|
export const A_K = 0.7;
|
|
export const ALBEDO_GRASS = 0.23;
|
|
export const ALBEDO_CONCRETE = 0.30;
|
|
|
|
// -------------------------------------------------------------------
|
|
// CONCRETE SURFACE TEMPERATURE (Urban profile)
|
|
// -------------------------------------------------------------------
|
|
// Estimates the surface temperature of exposed concrete using a
|
|
// multi-factor energy-balance approach. Six physical effects are
|
|
// modelled beyond the basic solar-gain / convective-loss pair:
|
|
//
|
|
// 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, solElev, uv, cloudCat, soilM, precip, snow) {
|
|
if (globalRad == null || Ta == null) return null;
|
|
|
|
// -- 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));
|
|
|
|
// -- Dry surface temperature ------------------------------------------
|
|
const Ts = Ta + absorbed / hc;
|
|
|
|
// -- 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;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// UK HOUSE INDOOR TEMPERATURE (windows closed, no active cooling)
|
|
// -------------------------------------------------------------------
|
|
// Estimates the ambient indoor air temperature of a typical UK brick
|
|
// house with windows closed and no air conditioning.
|
|
//
|
|
// Two heat pathways are modelled:
|
|
//
|
|
// 1. WALL CONDUCTION
|
|
// Heat conducts through brick cavity walls and roof. UK Part L
|
|
// compliant walls have a U-value around 0.28-0.45 W/m-K; older
|
|
// solid-brick stock runs higher. A representative mid-stock value
|
|
// is used. This drives a slow, steady heat transfer proportional
|
|
// to the difference between outdoor and indoor air temperature.
|
|
//
|
|
// 2. WINDOW SOLAR GAIN
|
|
// A typical UK semi has ~15-18% glazing ratio. Solar energy
|
|
// transmits through glass, is absorbed by floors and furniture,
|
|
// and heats the indoor air. Gain is averaged across orientations
|
|
// (not all windows face south). Diffuse radiation contributes
|
|
// regardless of sun angle.
|
|
//
|
|
// THERMAL LAG
|
|
// Brick and concrete have high thermal mass - the house responds
|
|
// slowly to outdoor temperature swings. Each hour builds a realistic
|
|
// target temperature from outdoor air, window solar gain, retained
|
|
// warmth, and internal gains, then the room temperature lags toward
|
|
// that target. This avoids runaway accumulation while still giving
|
|
// the characteristic late-day indoor peak.
|
|
// Call calcIndoorTempPass() on the full hourly arrays after
|
|
// building rows - it returns a per-hour indoor temp array.
|
|
//
|
|
// No mechanical cooling. Minimal infiltration (windows closed).
|
|
// Internal heat gains (people, appliances) are not modelled.
|
|
//
|
|
// Colour thresholds:
|
|
// < 20 -C - cool, may need heating
|
|
// 20-26 -C - comfortable
|
|
// 26-32 -C - warm; WHO heatwave advisory threshold for sleeping
|
|
// > 32 -C - hot; risk for elderly and vulnerable occupants
|
|
// -------------------------------------------------------------------
|
|
|
|
// Two-pass function: call with the full arrays of hourly Ta and globalRad.
|
|
// Returns an array of indoor temperatures, one per hour.
|
|
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
|
|
export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
|
|
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
|
|
const { lagHours, glazingRatio, gValue, orientFactor, solarScale, baseTemp, internalGain, retainedScale } = preset;
|
|
|
|
const n = TaArr.length;
|
|
const result = new Array(n);
|
|
const alpha = 1 - Math.exp(-1 / lagHours); // per-hour blending weight
|
|
|
|
// Seed to a plausible occupied indoor baseline rather than outdoor air.
|
|
let Ti = Math.max(TaArr[0] ?? 15, baseTemp ?? 16);
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const Ta = TaArr[i] ?? Ti;
|
|
const glob = globArr[i] ?? 0;
|
|
|
|
// Solar gain through windows (W/m- effective)
|
|
const solarGain = glob * glazingRatio * gValue * orientFactor;
|
|
|
|
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
|
|
const target = Ta + solarGain * (solarScale ?? 0.1) + retainedWarmth + (internalGain ?? 0.7);
|
|
|
|
// Apply thermal lag: blend toward the hour's target instead of adding
|
|
// solar gain repeatedly onto the previous indoor temperature.
|
|
Ti = Ti + alpha * (target - Ti);
|
|
|
|
// Can't be colder than outdoor (house doesn't actively cool)
|
|
result[i] = Math.max(Math.min(Ti, 55), Math.min(Ta, Ti));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// UK HOUSE INDOOR TEMPERATURE - MANAGED (curtains closed, windows open)
|
|
// -------------------------------------------------------------------
|
|
// Models the same UK brick house as calcIndoorTempPass but with two
|
|
// behavioural interventions that reflect standard heatwave advice:
|
|
//
|
|
// 1. CURTAINS CLOSED
|
|
// Thick curtains block ~80% of window solar gain before it enters
|
|
// the room. The small remaining fraction is diffuse light through
|
|
// the curtain fabric. Wall conduction is unchanged.
|
|
//
|
|
// 2. WINDOWS OPEN (smart ventilation)
|
|
// When outdoor air is cooler than the indoor air, windows are open
|
|
// and a ventilation heat exchange pulls the indoor temp toward
|
|
// outdoor. When outdoor is hotter than indoor, windows are kept
|
|
// shut - so this strategy never makes things worse, only better.
|
|
// Ventilation rate ~2 air changes/hour for a well-opened house.
|
|
//
|
|
// Same thermal lag model as calcIndoorTempPass (4 h brick time constant).
|
|
// -------------------------------------------------------------------
|
|
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
|
|
export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
|
|
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
|
|
const { lagHours, glazingRatio, gValue, orientFactor, curtainBlock, ventAlpha, solarScale, baseTemp, internalGain, retainedScale } = preset;
|
|
|
|
const n = TaArr.length;
|
|
const result = new Array(n);
|
|
const alpha = 1 - Math.exp(-1 / lagHours);
|
|
|
|
let Ti = Math.max(TaArr[0] ?? 15, baseTemp ?? 16);
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const Ta = TaArr[i] ?? Ti;
|
|
const glob = globArr[i] ?? 0;
|
|
|
|
// Solar gain - curtains block curtainBlock fraction
|
|
const solarGain = glob * glazingRatio * gValue * orientFactor * (1 - curtainBlock);
|
|
|
|
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
|
|
const target = Ta + solarGain * (solarScale ?? 0.1) + retainedWarmth + (internalGain ?? 0.7);
|
|
|
|
// Apply thermal lag toward the managed target.
|
|
Ti = Ti + alpha * (target - Ti);
|
|
|
|
// Smart ventilation: only open windows when outside is cooler
|
|
if (Ta < Ti) {
|
|
Ti = Ti + ventAlpha * (Ta - Ti);
|
|
}
|
|
|
|
result[i] = Math.max(Math.min(Ti, 55), Math.min(Ta, Ti));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// VEHICLE INTERIOR CABIN TEMPERATURE (seated occupant, not in sunbeam)
|
|
// -------------------------------------------------------------------
|
|
// Models the ambient cabin air temperature experienced by an occupant
|
|
// seated out of direct sunlight inside a sealed, parked vehicle.
|
|
// Two heat sources are combined:
|
|
//
|
|
// 1. PANEL CONDUCTION
|
|
// Aluminium body panels absorb solar radiation and conduct heat
|
|
// into the cabin. Albedo ~0.25 (mid-point for typical mixed-colour
|
|
// fleet; dark paint ~0.10, silver/white ~0.40).
|
|
// Panel surface temp - conductive gain into cabin air.
|
|
//
|
|
// 2. VERTICAL GLAZING GAIN (sun-angle dependent)
|
|
// Sun cuts through the side glass and windscreen and warms the cabin
|
|
// air, but the occupant is modelled as NOT sitting in the beam - so it
|
|
// adds to ambient cabin temp, not direct radiant load. The beam on
|
|
// vertical glass goes as cos(elevation) averaged over azimuth, over a
|
|
// diffuse floor that applies at any sun angle. A parked vehicle is not
|
|
// aimed at the sun, so only orientFactor of the glazing is catching it.
|
|
//
|
|
// 3. HORIZONTAL GLAZING GAIN (rooflights / panoramic roofs)
|
|
// Rooflights collect most strongly when the sun is high - the exact
|
|
// condition under which the vertical-glass term is tailing off. Scales
|
|
// with sin(elevation) and uses a lower transmission (0.40) because
|
|
// rooflights are typically smoked/tinted acrylic rather than clear glass.
|
|
//
|
|
// Ambient wind scrubs the bodywork whether or not the windows are open, so
|
|
// it feeds the shell convection coefficient in every case; road speed does
|
|
// the same job and the larger of the two wins. No evaporative cooling.
|
|
// Cars warm quickly; motorhomes and caravans are treated as insulated living
|
|
// spaces with 25-35 mm sandwich panels, so panel heat gain is much smaller.
|
|
// Their interiors are SLOWER, not COOLER - the insulation that keeps heat out
|
|
// also keeps it in, so a closed-up van ends up about as hot as a closed-up car
|
|
// once it has had a few hours to get there. Because they are occupied living
|
|
// spaces, they also retain a little warmth from previous hours, people,
|
|
// appliances, and background heating.
|
|
//
|
|
// Colour thresholds in the table:
|
|
// < 35 -C - warm but tolerable for short periods
|
|
// 35-45 -C - dangerous for children/pets (hyperthermia risk)
|
|
// > 45 -C - potentially fatal within minutes
|
|
// -------------------------------------------------------------------
|
|
export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car', ventilated = false, speedMph = 0, windMps = 0) {
|
|
if (globalRad == null || Ta == null) return null;
|
|
|
|
// Look up vehicle preset; fall back to a standard car if key unknown.
|
|
const preset = VEHICLE_TYPES[vehicleType] || VEHICLE_TYPES.car;
|
|
|
|
// Road speed in m/s. speedMph = 0 is "Static" (parked); higher speeds scrub
|
|
// the shell with forced airflow and (windows down) flush the cabin toward
|
|
// ambient.
|
|
const vMs = Math.max(0, speedMph) * 0.447; // mph -> m/s
|
|
|
|
// -- 1. Panel conduction ------------------------------------------
|
|
const panelAbsorbed = globalRad * (1 - preset.albedo); // W/m- absorbed by bodywork
|
|
// Panel surface temp: absorbed solar / convective loss to outside air.
|
|
// Convection over the shell is driven by whichever airflow is stronger -
|
|
// the ambient wind or the car's own road speed. A parked vehicle in a
|
|
// 15 mph wind has bodywork far closer to ambient than one in still air,
|
|
// so ambient wind must not be ignored just because the windows are shut.
|
|
// The 1.5 m/s floor is not fudge: hOut is steep near calm, and a reported
|
|
// wind of 0 m/s does not mean still air at the bodywork - thermal plumes off
|
|
// hot panels and ordinary gustiness keep it moving. Without the floor a dead
|
|
// calm hour sent a sealed car to ~67 -C. It only bites below ~3 mph, so it
|
|
// leaves the calibration anchors untouched.
|
|
const vShell = Math.max(1.5, vMs, Math.max(0, windMps || 0));
|
|
const hOut = 10 + 5.5 * Math.sqrt(vShell);
|
|
const panelSurfaceTemp = Ta + panelAbsorbed / hOut;
|
|
// Conductive gain into cabin. Cars are thin metal + trim; motorhomes and
|
|
// caravans use insulated sandwich panels, so their bodyU is much lower.
|
|
const hCabin = preset.bodyU ?? 4;
|
|
const conductionGain = hCabin * (panelSurfaceTemp - Ta); // W/m-
|
|
|
|
// -- 2. Vertical glazing gain (angle-dependent) --------------------
|
|
// Glazing transmission for auto glass ~0.70; scaled by vehicle glazing area.
|
|
//
|
|
// The beam landing on VERTICAL glass goes as cos(elevation) once averaged
|
|
// over azimuth, plus a diffuse floor of scattered sky light that gets in at
|
|
// any sun angle. orientFactor then accounts for the glazing that is NOT
|
|
// pointing at the sun - a parked vehicle is not aimed, and a motorhome's
|
|
// windscreen faces wherever it happened to park.
|
|
//
|
|
// This replaced a tent function that peaked at 35- elevation. That shape
|
|
// assumed the glass was always squarely aimed at the sun, so its factor
|
|
// nearly TRIPLED between 3pm and 5pm as the sun dropped toward the peak -
|
|
// sending a ventilated van climbing to +9 over ambient in the late
|
|
// afternoon when the measured excess stays flat around +5..6. It also had
|
|
// a discontinuity at its 10-/60- cut-offs where a LOWER sun gave MORE gain.
|
|
const tau = 0.70 * preset.glazingArea;
|
|
const angleFactor = (solElev != null && solElev > 0)
|
|
? 0.30 + 0.70 * Math.cos(solElev * Math.PI / 180)
|
|
: 0.30;
|
|
const glazingGain = tau * globalRad * angleFactor * (preset.orientFactor ?? 0.65) * 0.5;
|
|
|
|
// -- 3. Horizontal glazing gain (rooflights, panoramic roof) -------
|
|
// Rooflights collect in proportion to sin(elevation), so they peak at midday
|
|
// just as the vertical-glass term is falling away. Smoked acrylic transmits
|
|
// roughly 0.40 rather than the 0.70 of clear auto glass.
|
|
const roofGain = (solElev != null && solElev > 0)
|
|
? 0.40 * (preset.roofGlazing ?? 0) * globalRad * Math.sin(solElev * Math.PI / 180)
|
|
: 0;
|
|
|
|
// -- Combine into cabin air temperature ---------------------------
|
|
// Total heat input per m- of cabin surface
|
|
const totalGain = conductionGain + glazingGain + roofGain;
|
|
// Cabin heat rejection: an effective blend of leakage, internal air volume,
|
|
// and surfaces exchanging heat with the outside. Opening the windows when
|
|
// parked multiplies it by the preset's ventMult - a car with every window
|
|
// down flushes far harder per unit volume than a van with two windows and a
|
|
// rooflight open, so that multiplier is per-vehicle, not a shared constant.
|
|
// On the move the through-draught multiplies this further (a 70 mph open
|
|
// window flushes the cabin almost to ambient). Sealed but moving rejects a
|
|
// little faster too, because the cooler shell pulls cabin heat out.
|
|
const speedFactor = 1 + vMs / 12; // grows with road speed (windows-open draught)
|
|
const lossMult = ventilated ? (preset.ventMult ?? 4) * speedFactor : 1 + vMs / 40;
|
|
const effectiveHLoss = preset.hCabinLoss * lossMult;
|
|
const solarRise = totalGain / effectiveHLoss;
|
|
|
|
// Motorhomes/caravans behave more like small insulated rooms than parked
|
|
// cars. This term captures retained living-space warmth: strongest on cool
|
|
// days, tapering away as outdoor air warms, and reduced when ventilated.
|
|
// It used to be much larger (8 - 0.25*Ta) because it was silently standing
|
|
// in for solar gain the model was throwing away; now that the glazing terms
|
|
// are right it only has to cover occupancy and residual warmth.
|
|
const retainedWarmth = preset.retainedWarmth
|
|
? Math.max(0, 5 - 0.20 * Ta) * (ventilated ? 0.35 : 1)
|
|
: 0;
|
|
const internalGain = (preset.internalGain ?? 0) * (ventilated ? 0.35 : 1);
|
|
|
|
const Ti = Ta + solarRise + retainedWarmth + internalGain;
|
|
|
|
// Clamp: can't be cooler than outside air; physical cap at 90 -C
|
|
return Math.max(Ta, Math.min(Ti, 90));
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// VEHICLE INTERIOR TEMPERATURE - TWO-PASS (thermal lag)
|
|
// -------------------------------------------------------------------
|
|
// calcVehicleInteriorTemp above returns the EQUILIBRIUM cabin temperature
|
|
// for one hour's conditions - where the interior would settle if those
|
|
// conditions held. Real cabins take time to get there, so this pass relaxes
|
|
// toward that target with a per-vehicle time constant, exactly as
|
|
// calcConcreteTempPass and calcIndoorTempPass do.
|
|
//
|
|
// This replaces an earlier 'thermalMass' multiplier that scaled the
|
|
// equilibrium rise down (motorhomes ran at 0.35x). That conflated two
|
|
// different things: thermal mass delays how fast you reach equilibrium, it
|
|
// does not lower the equilibrium itself. A van parked in the sun since
|
|
// breakfast is close to equilibrium by mid-afternoon, so the multiplier
|
|
// under-predicted every long parked spell - badly enough that a ventilated
|
|
// motorhome came out barely a degree above ambient at peak sun.
|
|
//
|
|
// Cars use a short constant (~0.5 h - a car is hot within the half hour);
|
|
// motorhomes and caravans a longer one (~1.5-1.7 h) reflecting their larger
|
|
// air volume and heavier interior fit-out.
|
|
//
|
|
// vaArr is ambient wind in m/s (Open-Meteo wind_speed_10m with
|
|
// wind_speed_unit=ms) and may be null/omitted, in which case still air is
|
|
// assumed. speedMph is the selected road-speed class, not per-hour data.
|
|
// -------------------------------------------------------------------
|
|
export function calcVehicleInteriorTempPass(TaArr, globArr, elevArr, vaArr, vehicleType = 'car', ventilated = false, speedMph = 0) {
|
|
const preset = VEHICLE_TYPES[vehicleType] || VEHICLE_TYPES.car;
|
|
const n = TaArr.length;
|
|
const result = new Array(n);
|
|
const alpha = 1 - Math.exp(-1 / (preset.lagHours ?? 0.5));
|
|
|
|
// Seed at the first hour's air temp - a vehicle left overnight has
|
|
// equalised with the outside air.
|
|
let Ti = TaArr[0] ?? 15;
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const Ta = TaArr[i];
|
|
if (Ta == null) { result[i] = null; continue; }
|
|
const target = calcVehicleInteriorTemp(
|
|
Ta, globArr[i] ?? 0, elevArr[i], vehicleType, ventilated, speedMph,
|
|
vaArr ? (vaArr[i] ?? 0) : 0
|
|
);
|
|
if (target == null) { result[i] = null; continue; }
|
|
Ti = Ti + alpha * (target - Ti);
|
|
// Can't be cooler than outside air; physical cap at 90 -C.
|
|
result[i] = Math.max(Ta, Math.min(Ti, 90));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SHADE AIR TEMPERATURE (per-environment microclimate)
|
|
// -------------------------------------------------------------------
|
|
// Estimates the AIR temperature you'd actually experience sitting in
|
|
// the typical shade of the selected Solar Model - NOT the felt temp.
|
|
//
|
|
// Baseline: this builds on the SAME environment air temp that SunSoak
|
|
// uses - TaEnv = Ta + env.taOffset - so every Solar Model's air-temp
|
|
// shift (urban heat island, forest/river evapotranspiration cooling,
|
|
// desert baking) flows through here automatically and the two columns
|
|
// can never drift out of sync. On top of that env baseline we add only
|
|
// the LOCAL sun-trap effects: surrounding surfaces (walls, sand, rock)
|
|
// re-radiate heat into the still air pocket, while wind mixing pulls
|
|
// the pocket back toward the ambient reading. Crucially the sun-trap
|
|
// warming is driven by the environment-REDUCED radiation, so a forest
|
|
// canopy shades the sun-trap just as it shades the person.
|
|
//
|
|
// IMPORTANT: this is an air-temperature nudge, deliberately gentle.
|
|
// The radiant load of direct sun is already handled by Tmrt/SunSoak,
|
|
// so we do NOT re-add it here - that would double-count the sun.
|
|
//
|
|
// The environment air-temp shift is NOT re-specified here - it lives in
|
|
// env.taOffset and reaches us via the TaEnv passed in as `Ta`. The shade
|
|
// block only carries the two local micro-effects:
|
|
//
|
|
// shade.shelter - 0-1 wind shelter. High = enclosed (canyon, canopy),
|
|
// so wind mixing has little effect. Low = breezy
|
|
// (open water, single beach umbrella).
|
|
// shade.sun - 0-~1.2 surface solar gain. How sun-baked the
|
|
// surroundings are: hot concrete/sand high, shaded
|
|
// forest floor / cool water low. Drives how much the
|
|
// still air warms on a sunny hour.
|
|
//
|
|
// Behaviour: a calm sunny hour in a sheltered, sun-baked environment
|
|
// reads a degree or two above the environment air temp; a windy or
|
|
// overcast hour collapses back toward it. Cooling models (forest,
|
|
// river) sit below official Air because their taOffset already has.
|
|
//
|
|
// TaEnv - environment air temp (Ta + env.taOffset), the shared
|
|
// SunSoak baseline. Passed in as the `Ta` argument.
|
|
// effectiveRad - environment-REDUCED radiation reaching the sun-trap.
|
|
// -------------------------------------------------------------------
|
|
export const SHADE_K_SUN = 1.5; // surface-warming strength (per full-sun, fully sun-baked)
|
|
export const SHADE_K_WIND = 0.15; // wind-mixing strength (per m/s, fully exposed)
|
|
|
|
export function calcShadeAirTemp(Ta, effectiveRad, va, elev, shade) {
|
|
if (Ta == null || !shade) return Ta ?? null;
|
|
const rad = Math.max(0, Math.min(1, (effectiveRad || 0) / 800)); // 0-1, saturates ~800 W/m-
|
|
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);
|
|
// 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.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 -----------------------------------
|
|
// Cats and small dogs experience solar heat differently from bare pavement:
|
|
// a coat absorbs radiation like calcConcreteTemp()'s slab, but a thin/short
|
|
// coat over a living, blood-perfused body insulates less than a heavy double
|
|
// coat and never bakes as hot as dead concrete. furAlbedo varies by coat
|
|
// colour (FUR_COLORS in utils.js): black ~0.05, brown ~0.15, golden ~0.25,
|
|
// white ~0.40. Paw burn risk reuses calcConcreteTemp() output directly (the
|
|
// ground/pavement surface temp is already the right number for paw contact):
|
|
// < 40 -C - safe
|
|
// 40-52 -C - discomfort / possible burn (hold-your-hand-for-7-seconds test)
|
|
// > 52 -C - burns within 60 seconds
|
|
// A breed-specific multiplier (brachycephalic/senior) is deliberately not
|
|
// modelled - this profile targets cats and small dogs as the reference animal.
|
|
// -------------------------------------------------------------------------
|
|
export function calcFurSurfaceTemp(Ta, globalRad, windSpeed, solElev, furAlbedo = 0.15, uv, cloudCat) {
|
|
if (globalRad == null || Ta == null) return null;
|
|
const elevDeg = (solElev != null) ? Math.max(5, solElev) : 45;
|
|
const angleCorrection = Math.sin(elevDeg * Math.PI / 180);
|
|
// Same cloud/UV-clarity derating calcConcreteTemp() applies on top of the
|
|
// raw radiation figure - both models share the same input array
|
|
// (hourlyRows.effectiveRad), so without these fur was absorbing the full
|
|
// beam while concrete was further discounted for haze/cloud, making fur
|
|
// read hotter than pavement on anything but a clear sky (backwards from
|
|
// the "never bakes as hot as dead concrete" intent above).
|
|
const cloudTransmit = cloudCat === 'clear' ? 1.00
|
|
: cloudCat === 'wispy' ? 0.92
|
|
: cloudCat === 'scattered' ? 0.72
|
|
: cloudCat === 'overcast' ? 0.28
|
|
: 1.00;
|
|
const uvClarity = (uv && uv > 0)
|
|
? 0.85 + 0.15 * Math.min(1, uv / 8)
|
|
: 1.0;
|
|
const absorbed = globalRad * (1 - furAlbedo) * angleCorrection * cloudTransmit * uvClarity;
|
|
// Lower convective coefficient than bare concrete (hc=10+4.5*sqrt(v)) -
|
|
// trapped air in the coat insulates, running the surface hotter for a
|
|
// given wind speed than pavement, but not as insulated as a thick double
|
|
// coat, so it sits between "bare skin" and "heavy fur".
|
|
const hc = 7 + 3.5 * Math.sqrt(Math.max(windSpeed || 0, 0));
|
|
const Ts = Ta + absorbed / hc;
|
|
// Floor at air temp; cap below concrete's 85-C ceiling - a thin coat over
|
|
// a living body doesn't bake as hot as dead pavement. No wet-fur/rain
|
|
// evaporative term yet (future refinement, mirrors calcConcreteTemp's).
|
|
return Math.max(Ta, Math.min(Ts, 70));
|
|
}
|
|
|
|
export const LAG_HOURS_FUR = 0.4; // ~24 min - low thermal mass, animal moves in/out of sun
|
|
|
|
export function calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo = 0.15, uvArr, cloudCatArr) {
|
|
const n = TaArr.length;
|
|
const result = new Array(n);
|
|
const alpha = 1 - Math.exp(-1 / LAG_HOURS_FUR);
|
|
let Tf = calcFurSurfaceTemp(TaArr[0], radArr[0], vaArr[0], elevArr[0], furAlbedo, uvArr?.[0], cloudCatArr?.[0]) ?? TaArr[0];
|
|
for (let i = 0; i < n; i++) {
|
|
const target = calcFurSurfaceTemp(TaArr[i], radArr[i], vaArr[i], elevArr[i], furAlbedo, uvArr?.[i], cloudCatArr?.[i]) ?? TaArr[i];
|
|
Tf = Tf + alpha * (target - Tf);
|
|
result[i] = Math.max(TaArr[i], Tf);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// -- Vehicle-at-speed thermal model (IMPLEMENTED) ------------------------------
|
|
// calcVehicleInteriorTemp now takes a speedMph argument (Static / 20 / 50 / 70
|
|
// mph in the UI). Forced convection over the shell scales with road speed, and
|
|
// windows-open through-flow scales further with speed, so a moving cabin runs
|
|
// cooler than the same parked car. speedMph = 0 is the parked case - which is
|
|
// no longer "no airflow", since ambient wind now drives shell convection too.
|
|
//
|
|
// -- CYCLIST AT SPEED (FUTURE) ------------------------------------------------
|
|
// A cyclist generates their own headwind, so the felt temperature (UTCI) is
|
|
// very different from a stationary person - even without ambient wind.
|
|
// Could share the same speed-class approach as the vehicle model:
|
|
// Slow (10 mph) / Moderate (15 mph) / Fast (25 mph)
|
|
//
|
|
// Key differences from the vehicle model:
|
|
// - The cyclist IS the exposed person - use the UTCI polynomial directly
|
|
// with va = max(ambientWind, cyclingSpeed * conversionFactor)
|
|
// - No cabin heating effect - the cyclist gets wind chill, not solar entrapment
|
|
// - High metabolic heat generation raises core temp (links to the activity
|
|
// modifier planned in compute.js - cycling at speed is a combined effect)
|
|
// - UVA/UVB exposure is unchanged - still fully exposed to the sun
|
|
//
|
|
// Could be a sub-option within the existing Cycling activity variant rather
|
|
// than a separate column - e.g. a speed picker in the variant controls.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// -------------------------------------------------------------------
|
|
// VAPOUR PRESSURE - Magnus formula.
|
|
// -------------------------------------------------------------------
|
|
// Converts air temperature (-C) and relative humidity (%) to vapour
|
|
// pressure in hPa. Used as the humidity input to utciApprox().
|
|
// -------------------------------------------------------------------
|
|
export function vaporPressureHpa(Ta, RH) {
|
|
const es = 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
|
|
return es * (RH / 100);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SOLAR ELEVATION - NOAA simplified algorithm (degrees above horizon).
|
|
// -------------------------------------------------------------------
|
|
// Accurate to within ~0.01- for most practical purposes. Returns a
|
|
// negative value when the sun is below the horizon (civil twilight
|
|
// starts at -6-, nautical at -12-, astronomical at -18-).
|
|
// -------------------------------------------------------------------
|
|
export function solarElevationDeg(lat, lon, dateUTC) {
|
|
const start = Date.UTC(dateUTC.getUTCFullYear(), 0, 0);
|
|
const diff = dateUTC - start;
|
|
const DOY = Math.floor(diff / 86400000);
|
|
const hourUTC =
|
|
dateUTC.getUTCHours() +
|
|
dateUTC.getUTCMinutes() / 60 +
|
|
dateUTC.getUTCSeconds() / 3600;
|
|
const gamma = ((2 * Math.PI) / 365) * (DOY - 1 + (hourUTC - 12) / 24);
|
|
const eqtime =
|
|
229.18 *
|
|
(0.000075 +
|
|
0.001868 * Math.cos(gamma) -
|
|
0.032077 * Math.sin(gamma) -
|
|
0.014615 * Math.cos(2 * gamma) -
|
|
0.040849 * Math.sin(2 * gamma));
|
|
const decl =
|
|
0.006918 -
|
|
0.399912 * Math.cos(gamma) +
|
|
0.070257 * Math.sin(gamma) -
|
|
0.006758 * Math.cos(2 * gamma) +
|
|
0.000907 * Math.sin(2 * gamma) -
|
|
0.002697 * Math.cos(3 * gamma) +
|
|
0.00148 * Math.sin(3 * gamma);
|
|
const timeOffset = eqtime + 4 * lon;
|
|
const tst = hourUTC * 60 + timeOffset;
|
|
const ha = (((tst / 4) - 180) * Math.PI) / 180;
|
|
const latRad = (lat * Math.PI) / 180;
|
|
const cosZenith =
|
|
Math.sin(latRad) * Math.sin(decl) +
|
|
Math.cos(latRad) * Math.cos(decl) * Math.cos(ha);
|
|
const zenith = Math.acos(Math.max(-1, Math.min(1, cosZenith)));
|
|
return (Math.PI / 2 - zenith) * (180 / Math.PI);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// MEAN RADIANT TEMPERATURE (Tmrt)
|
|
// -------------------------------------------------------------------
|
|
// Tmrt is the uniform temperature of an imaginary enclosure that would
|
|
// cause the same net radiation exchange as the actual environment.
|
|
// It accounts for:
|
|
// - Direct solar beam (DNI), scaled by the projected-area factor fp
|
|
// - Diffuse sky radiation (scattered and cloud-reflected)
|
|
// - Ground-reflected shortwave (albedo - global radiation)
|
|
// - Longwave thermal emission from surrounding surfaces (- blackbody at Ta)
|
|
//
|
|
// The fabric index 0.308 (fp formula from ISO 7933) projects the sun
|
|
// onto a standing person's silhouette as a function of solar elevation.
|
|
// Output feeds directly into utciApprox() as the Tmrt argument.
|
|
// -------------------------------------------------------------------
|
|
export function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) {
|
|
const TaK = Ta + 273.15;
|
|
let fp = 0;
|
|
if (solElev > 0) {
|
|
const h2 = solElev;
|
|
fp = 0.308 * Math.cos((Math.PI / 180) * h2 * (0.998 - (h2 * h2) / 50000));
|
|
}
|
|
let DNI = 0;
|
|
if (solElev > 1) {
|
|
DNI = dirRad / Math.sin((solElev * Math.PI) / 180);
|
|
DNI = Math.min(DNI, 1100);
|
|
}
|
|
const Sshort = A_K * (fp * DNI + 0.5 * diffRad + 0.5 * ALBEDO_GRASS * globalRad);
|
|
const Slong = EPSILON_P * SIGMA * Math.pow(TaK, 4);
|
|
const TmrtK = Math.pow((Sshort + Slong) / (EPSILON_P * SIGMA), 0.25);
|
|
return TmrtK - 273.15;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// UTCI POLYNOMIAL - DO NOT EDIT (or be VERY careful if you do)
|
|
// -------------------------------------------------------------------
|
|
// This is the official 210-term Br-de et al. (2012) approximation.
|
|
// It takes air temp, mean radiant temp, wind, and humidity and returns
|
|
// the "felt" temperature. Every number is a peer-reviewed coefficient.
|
|
// Scroll past it - there's nothing here you'll want to change.
|
|
// -------------------------------------------------------------------
|
|
export function utciApprox(Ta, Tmrt, va10, ehPa) {
|
|
const va = Math.max(0.5, Math.min(17, va10));
|
|
// Clamp Tmrt-Ta to the polynomial's validated domain. The UTCI fit is only
|
|
// valid for a mean-radiant offset of -30..+70 -C; on hot urban/concrete
|
|
// profiles the modelled Tmrt can exceed Ta by more than +70-, where the
|
|
// polynomial extrapolates and loses accuracy. Clamping keeps the felt
|
|
// temperature inside the range the coefficients were derived for.
|
|
const D_Tmrt = Math.max(-30, Math.min(70, Tmrt - Ta));
|
|
const Pa = ehPa / 10;
|
|
const T = Ta, V = va, D = D_Tmrt, P = Pa;
|
|
const T2=T*T, T3=T2*T, T4=T3*T, T5=T4*T, T6=T5*T;
|
|
const V2=V*V, V3=V2*V, V4=V3*V, V5=V4*V, V6=V5*V;
|
|
const D2=D*D, D3=D2*D, D4=D3*D, D5=D4*D, D6=D5*D;
|
|
const P2=P*P, P3=P2*P, P4=P3*P, P5=P4*P, P6=P5*P;
|
|
return T +
|
|
6.07562052e-1 +
|
|
-2.27712343e-2 * T + 8.06470249e-4 * T2 + -1.54271372e-4 * T3 +
|
|
-3.24651735e-6 * T4 + 7.32602852e-8 * T5 + 1.35959073e-9 * T6 +
|
|
-2.25836520e0 * V + 8.80326035e-2 * T*V + 2.16844454e-3 * T2*V +
|
|
-1.53347087e-5 * T3*V + -5.72983704e-7 * T4*V + -2.55090145e-9 * T5*V +
|
|
-7.51269505e-1 * V2 + -4.08350271e-3 * T*V2 + -5.21670675e-5 * T2*V2 +
|
|
1.94544667e-6 * T3*V2 + 1.14099531e-8 * T4*V2 +
|
|
1.58137256e-1 * V3 + -6.57263143e-5 * T*V3 + 2.22697524e-7 * T2*V3 +
|
|
-4.16117031e-8 * T3*V3 +
|
|
-1.27762753e-2 * V4 + 9.66891875e-6 * T*V4 + 2.52785852e-9 * T2*V4 +
|
|
4.56306672e-4 * V5 + -1.74202546e-7 * T*V5 +
|
|
-5.91491269e-6 * V6 +
|
|
3.98374029e-1 * D + 1.83945314e-4 * T*D + -1.73754510e-4 * T2*D +
|
|
-7.60781159e-7 * T3*D + 3.77830287e-8 * T4*D + 5.43079673e-10 * T5*D +
|
|
-2.00518269e-2 * V*D + 8.92859837e-4 * T*V*D + 3.45433048e-6 * T2*V*D +
|
|
-3.77925774e-7 * T3*V*D + -1.69699377e-9 * T4*V*D +
|
|
1.69992415e-4 * V2*D + -4.99204314e-5 * T*V2*D + 2.47417178e-7 * T2*V2*D +
|
|
1.07596466e-8 * T3*V2*D +
|
|
8.49242932e-5 * V3*D + 1.35191328e-6 * T*V3*D + -6.21531254e-9 * T2*V3*D +
|
|
-4.99410301e-6 * V4*D + -1.89489258e-8 * T*V4*D +
|
|
8.15300114e-8 * V5*D +
|
|
7.55043090e-4 * D2 + -5.65095215e-5 * T*D2 + -4.52166564e-7 * T2*D2 +
|
|
2.46688878e-8 * T3*D2 + 2.42674348e-10 * T4*D2 +
|
|
1.54547250e-4 * V*D2 + 5.24110970e-6 * T*V*D2 + -8.75874982e-8 * T2*V*D2 +
|
|
-1.50743064e-9 * T3*V*D2 +
|
|
-1.56236307e-5 * V2*D2 + -1.33895614e-7 * T*V2*D2 + 2.49709824e-9 * T2*V2*D2 +
|
|
6.51711721e-7 * V3*D2 + 1.94960053e-9 * T*V3*D2 +
|
|
-1.00361113e-8 * V4*D2 +
|
|
-1.21206673e-5 * D3 + -2.18203660e-7 * T*D3 + 7.51269482e-9 * T2*D3 +
|
|
9.79063848e-11 * T3*D3 +
|
|
1.25006734e-6 * V*D3 + -1.81584736e-9 * T*V*D3 + -3.52197671e-10 * T2*V*D3 +
|
|
-3.36514630e-8 * V2*D3 + 1.35908359e-10 * T*V2*D3 +
|
|
4.17032620e-10 * V3*D3 +
|
|
-1.30369025e-9 * D4 + 4.13908461e-10 * T*D4 + 9.22652254e-12 * T2*D4 +
|
|
-5.08220384e-9 * V*D4 + -2.24730961e-11 * T*V*D4 +
|
|
1.17139133e-10 * V2*D4 +
|
|
6.62154879e-10 * D5 + 4.03863260e-13 * T*D5 + 1.95087203e-12 * V*D5 +
|
|
-4.73602469e-12 * D6 +
|
|
5.12733497e0 * P + -3.12788561e-1 * T*P + -1.96701861e-2 * T2*P +
|
|
9.99690870e-4 * T3*P + 9.51738512e-6 * T4*P + -4.66426341e-7 * T5*P +
|
|
5.48050612e-1 * V*P + -3.30552823e-3 * T*V*P + -1.64119440e-3 * T2*V*P +
|
|
-5.16670694e-6 * T3*V*P + 9.52692432e-7 * T4*V*P +
|
|
-4.29223622e-2 * V2*P + 5.00845667e-3 * T*V2*P + 1.00601257e-6 * T2*V2*P +
|
|
-1.81748644e-6 * T3*V2*P +
|
|
-1.25813502e-3 * V3*P + -1.79330391e-4 * T*V3*P + 2.34994441e-6 * T2*V3*P +
|
|
1.29735808e-4 * V4*P + 1.29064870e-6 * T*V4*P +
|
|
-2.28558686e-6 * V5*P +
|
|
-3.69476348e-2 * D*P + 1.62325322e-3 * T*D*P + -3.14279680e-5 * T2*D*P +
|
|
2.59835559e-6 * T3*D*P + -4.77136523e-8 * T4*D*P +
|
|
8.64203390e-3 * V*D*P + -6.87405181e-4 * T*V*D*P + -9.13863872e-6 * T2*V*D*P +
|
|
5.15916806e-7 * T3*V*D*P +
|
|
-3.59217476e-5 * V2*D*P + 3.28696511e-5 * T*V2*D*P + -7.10542454e-7 * T2*V2*D*P +
|
|
-1.24382300e-5 * V3*D*P + -7.38584400e-9 * T*V3*D*P +
|
|
2.20609296e-7 * V4*D*P +
|
|
-7.32469180e-4 * D2*P + -1.87381964e-5 * T*D2*P + 4.80925239e-6 * T2*D2*P +
|
|
-8.75492040e-8 * T3*D2*P +
|
|
2.77862930e-5 * V*D2*P + -5.06004592e-6 * T*V*D2*P + 1.14325367e-7 * T2*V*D2*P +
|
|
2.53016723e-6 * V2*D2*P + -1.72857035e-8 * T*V2*D2*P +
|
|
-3.95079398e-8 * V3*D2*P +
|
|
-3.59413173e-7 * D3*P + 7.04388046e-7 * T*D3*P + -1.89309167e-8 * T2*D3*P +
|
|
-4.79768731e-7 * V*D3*P + 7.96079978e-9 * T*V*D3*P +
|
|
1.62897058e-9 * V2*D3*P +
|
|
3.94367674e-8 * D4*P + -1.18566247e-9 * T*D4*P +
|
|
3.34678041e-10 * V*D4*P +
|
|
-1.15606447e-10 * D5*P +
|
|
-2.80626406e0 * P2 + 5.48712484e-1 * T*P2 + -3.99428410e-3 * T2*P2 +
|
|
-9.54009191e-4 * T3*P2 + 1.93090978e-5 * T4*P2 +
|
|
-3.08806365e-1 * V*P2 + 1.16952364e-2 * T*V*P2 + 4.95271903e-4 * T2*V*P2 +
|
|
-1.90710882e-5 * T3*V*P2 +
|
|
2.10787756e-3 * V2*P2 + -6.98445738e-4 * T*V2*P2 + 2.30109073e-5 * T2*V2*P2 +
|
|
4.17856590e-4 * V3*P2 + -1.27043871e-5 * T*V3*P2 +
|
|
-3.04620472e-6 * V4*P2 +
|
|
5.14507424e-2 * D*P2 + -4.32510997e-3 * T*D*P2 + 8.99281156e-5 * T2*D*P2 +
|
|
-7.14663943e-7 * T3*D*P2 +
|
|
-2.66016305e-4 * V*D*P2 + 2.63789586e-4 * T*V*D*P2 + -7.01199003e-6 * T2*V*D*P2 +
|
|
-1.06823306e-4 * V2*D*P2 + 3.61341136e-6 * T*V2*D*P2 +
|
|
2.29748967e-7 * V3*D*P2 +
|
|
3.04788893e-4 * D2*P2 + -6.42070836e-5 * T*D2*P2 + 1.16257971e-6 * T2*D2*P2 +
|
|
7.68023384e-6 * V*D2*P2 + -5.47446896e-7 * T*V*D2*P2 +
|
|
-3.59937910e-8 * V2*D2*P2 +
|
|
-4.36497725e-6 * D3*P2 + 1.68737969e-7 * T*D3*P2 +
|
|
2.67489271e-8 * V*D3*P2 +
|
|
3.23926897e-9 * D4*P2 +
|
|
-3.53874123e-2 * P3 + -2.21201190e-1 * T*P3 + 1.55126038e-2 * T2*P3 +
|
|
-2.63917279e-4 * T3*P3 +
|
|
4.53433455e-2 * V*P3 + -4.32943862e-3 * T*V*P3 + 1.45389826e-4 * T2*V*P3 +
|
|
2.17508610e-4 * V2*P3 + -6.66724702e-5 * T*V2*P3 +
|
|
3.33217140e-5 * V3*P3 +
|
|
-2.26921615e-3 * D*P3 + 3.80261982e-4 * T*D*P3 + -5.45314314e-9 * T2*D*P3 +
|
|
-7.96355448e-4 * V*D*P3 + 2.53458034e-5 * T*V*D*P3 +
|
|
-6.31223658e-6 * V2*D*P3 +
|
|
3.02122035e-4 * D2*P3 + -4.77403547e-6 * T*D2*P3 +
|
|
1.73825715e-6 * V*D2*P3 +
|
|
-4.09087898e-7 * D3*P3 +
|
|
6.14155345e-1 * P4 + -6.16755931e-2 * T*P4 + 1.33374846e-3 * T2*P4 +
|
|
3.55375387e-3 * V*P4 + -5.13027851e-4 * T*V*P4 +
|
|
1.02449757e-4 * V2*P4 +
|
|
-1.48526421e-3 * D*P4 + -4.11469183e-5 * T*D*P4 +
|
|
-6.80434415e-6 * V*D*P4 +
|
|
-9.77675906e-6 * D2*P4 +
|
|
8.82773108e-2 * P5 + -3.01859306e-3 * T*P5 +
|
|
1.04452989e-3 * V*P5 +
|
|
2.47090539e-4 * D*P5 +
|
|
1.48348065e-3 * P6;
|
|
}
|