New thermal charts
This commit is contained in:
fraxle
2026-05-15 14:00:54 +01:00
parent 153029c645
commit dd5b44ece3
4 changed files with 79 additions and 57 deletions
+40 -19
View File
@@ -113,7 +113,7 @@ export function UTCIForecast() {
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
// When this is wired to real billing/auth, replace `useState(false)`
// with a check against the logged-in user.
const [isPro, setIsPro] = useState(true);
const [isPro, setIsPro] = useState(false);
// How many days the free tier shows. Days beyond this get a 🔒.
// Bump this number if you want to give free users more access.
@@ -250,7 +250,7 @@ export function UTCIForecast() {
soilM: { title: 'Soil Moisture', desc: 'Volumetric water content of the top 1 cm of soil (m³/m³). Values above 0.4 suggest saturated ground; below 0.2 indicates dry conditions.' },
concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 1525 °C above air temperature on sunny days.' },
vehicleT: { title: 'Vehicle Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces with retained warmth, lower glass gain, and slower heat response. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' },
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a typical UK brick house with windows closed and no air conditioning. Accounts for wall conduction, window solar gain, and thermal mass — indoor temperatures typically peak 24 hours after the outdoor peak.' },
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.' },
managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' },
};
@@ -1233,9 +1233,29 @@ export function UTCIForecast() {
if (t < 50) return 'rgba(210, 70, 50,0.10)';
return 'rgba(160, 30, 30,0.10)';
};
const scaleBg = (v, min, max, rgb, maxAlpha = 0.14) => {
if (v == null || isNaN(v)) return 'transparent';
const x = Math.max(0, Math.min(1, (v - min) / (max - min)));
const a = 0.035 + x * maxAlpha;
return `rgba(${rgb},${a.toFixed(3)})`;
};
const deltaBg = (v) => {
if (v == null || isNaN(v)) return 'transparent';
if (Math.abs(v) < 1) return 'transparent';
const x = Math.min(1, Math.abs(v) / 12);
const a = 0.035 + x * 0.13;
return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`;
};
const burnBg = (mins) => {
if (!isFinite(mins)) return 'transparent';
if (mins >= 240) return 'rgba(110,180,110,0.07)';
const x = Math.max(0, Math.min(1, (240 - mins) / 220));
return `rgba(210,70,50,${(0.04 + x * 0.15).toFixed(3)})`;
};
// r.iso is the local wall-clock string from the API — slice it directly.
const h24 = parseInt(r.iso.slice(11, 13), 10);
const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`;
const burnMins = sunburnMinutes(r.uv, skinType);
return html`
<tr key=${r.iso}
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
@@ -1246,19 +1266,19 @@ export function UTCIForecast() {
</span>
</td>
${visibleCols.air && html`<td style=${{ background: tempBg(r.Ta) }}>${r.Ta.toFixed(1)}</td>`}
${visibleCols.rh && html`<td>${Math.round(r.RH)}</td>`}
${visibleCols.dew && html`<td>${r.dew != null ? r.dew.toFixed(1) : ''}</td>`}
${visibleCols.rh && html`<td style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}</td>`}
${visibleCols.dew && html`<td style=${{ background: tempBg(r.dew) }}>${r.dew != null ? r.dew.toFixed(1) : ''}</td>`}
${visibleCols.soilT && html`
<td style=${{ color: '#6b4a1c', background: tempBg(r.soilT0) }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : ''}</td>`}
${visibleCols.soilT6 && html`
<td style=${{ color: '#6b4a1c' }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : ''}</td>`}
<td style=${{ color: '#6b4a1c', background: tempBg(r.soilT6) }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : ''}</td>`}
${visibleCols.soilM && html`
<td style=${{ color: '#2a6a90' }}>${r.soilM != null ? r.soilM.toFixed(3) : ''}</td>`}
<td style=${{ color: '#2a6a90', background: scaleBg(r.soilM, 0.12, 0.45, '50,125,190') }}>${r.soilM != null ? r.soilM.toFixed(3) : ''}</td>`}
${visibleCols.concreteT && html`
<td style=${{ color: r.concreteT != null && r.concreteT > 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.concreteT) }}>
${r.concreteT != null ? r.concreteT.toFixed(1) : ''}
</td>`}
${visibleCols.wind && html`<td>
${visibleCols.wind && html`<td style=${{ background: scaleBg(r.gust ?? r.va, 0, 18, '85,130,180') }}>
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
: ''}
@@ -1269,40 +1289,41 @@ export function UTCIForecast() {
<span class="wind-dir-label" style=${{ fontFamily: 'JetBrains Mono, monospace', fontSize: '11px' }}>${r.compass.label}</span>
</span>
</td>`}
${visibleCols.cloud && html`<td>
${visibleCols.cloud && html`<td style=${{ background: scaleBg(r.cc, 0, 100, '110,130,150') }}>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} />
<span>${Math.round(r.cc)}</span>
</span>
</td>`}
${visibleCols.sun && html`<td>${r.elev > 0 ? r.elev.toFixed(1) : ''}</td>`}
${visibleCols.direct && html`<td>${Math.round(r.dir)}</td>`}
${visibleCols.diffuse && html`<td>${Math.round(r.dif)}</td>`}
${visibleCols.tmrt && html`<td>${r.Tmrt.toFixed(1)}</td>`}
${visibleCols.sun && html`<td style=${{ background: scaleBg(Math.max(0, r.elev), 0, 70, '225,160,45') }}>${r.elev > 0 ? r.elev.toFixed(1) : ''}</td>`}
${visibleCols.direct && html`<td style=${{ background: scaleBg(r.dir, 0, 850, '230,155,35') }}>${Math.round(r.dir)}</td>`}
${visibleCols.diffuse && html`<td style=${{ background: scaleBg(r.dif, 0, 450, '230,190,70') }}>${Math.round(r.dif)}</td>`}
${visibleCols.tmrt && html`<td style=${{ background: tempBg(r.Tmrt) }}>${r.Tmrt.toFixed(1)}</td>`}
${visibleCols.delta && html`
<td style=${{
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218',
fontWeight: 600,
background: deltaBg(delta),
}}>
${delta > 0 ? '+' : ''}${delta.toFixed(1)}
</td>`}
${visibleCols.utci && html`
<td>
<td style=${{ background: tempBg(r.utci) }}>
<span class="utci-cell" style=${{ background: cat.bg, color: cat.fg }}>
${r.utci.toFixed(1)}
</span>
</td>`}
${visibleCols.uvA && html`
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218' }}>
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}>
${r.uvA > 0 ? r.uvA.toFixed(1) : ''}
</td>`}
${visibleCols.uvB && html`
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218' }}>
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}>
${r.uvB > 0 ? r.uvB.toFixed(2) : ''}
</td>`}
${visibleCols.burn && html`
<td style=${{ color: r.uv > 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#4a3218' }}>
${burnLabel(sunburnMinutes(r.uv, skinType))}
<td style=${{ color: r.uv > 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins) }}>
${burnLabel(burnMins)}
</td>`}
${visibleCols.vehicleT && html`
<td style=${{ color: r.vehicleT != null && r.vehicleT > 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.vehicleT) }}>
@@ -1317,7 +1338,7 @@ export function UTCIForecast() {
${r.managedT != null ? r.managedT.toFixed(1) : ''}
</td>`}
${visibleCols.precip && html`
<td>
<td style=${{ background: r.snow > 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '5px', verticalAlign: 'middle' }}>
<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${28} />
<span style=${{ color: r.snow > 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}>
@@ -1326,7 +1347,7 @@ export function UTCIForecast() {
</span>
</td>`}
${visibleCols.utciP && html`
<td style=${{ background: 'rgba(180,215,250,0.10)' }}>
<td style=${{ background: tempBg(r.utciAdj) }}>
<span class="utci-cell utci-cell-hero" style=${{ background: adjCat.bg, color: adjCat.fg }}>
${r.utciAdj.toFixed(1)}
</span>
+19 -25
View File
@@ -86,10 +86,11 @@ export function calcConcreteTemp(Ta, globalRad, windSpeed) {
//
// THERMAL LAG
// Brick and concrete have high thermal mass — the house responds
// slowly to outdoor temperature swings. This function takes a
// weighted average of the current hour's heat load and the
// previous few hours', giving the characteristic lag where indoor
// temperature peaks 24 hours after the outdoor peak.
// 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.
//
@@ -125,14 +126,14 @@ function _houseHeatLoad(Ta, globalRad, solElev) {
// 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 { uWall, lagHours, glazingRatio, gValue, orientFactor } = preset;
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 indoor temp to first outdoor temp
let Ti = TaArr[0] ?? 15;
// 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;
@@ -141,16 +142,12 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'bric
// Solar gain through windows (W/m² effective)
const solarGain = glob * glazingRatio * gValue * orientFactor;
// Conductive heat flow through walls: proportional to (Ta - Ti)
const conductionGain = uWall * (Ta - Ti);
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
const target = Ta + solarGain * (solarScale ?? 0.1) + retainedWarmth + (internalGain ?? 0.7);
// Heat capacity proxy: how many °C does 1 W/m² raise the indoor air?
// Typical 90 m² house ≈ 0.15; conservatory much lower (less thermal mass).
const heatCapProxy = 0.15;
const Ti_instant = Ti + (conductionGain + solarGain) * heatCapProxy;
// Apply thermal lag: blend toward Ti_instant at the building's time constant
Ti = Ti + alpha * (Ti_instant - Ti);
// 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));
@@ -182,13 +179,13 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'bric
// 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 { uWall, lagHours, glazingRatio, gValue, orientFactor, curtainBlock, ventAlpha } = preset;
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 = TaArr[0] ?? 15;
let Ti = Math.max(TaArr[0] ?? 15, baseTemp ?? 16);
for (let i = 0; i < n; i++) {
const Ta = TaArr[i] ?? Ti;
@@ -197,14 +194,11 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType
// Solar gain — curtains block curtainBlock fraction
const solarGain = glob * glazingRatio * gValue * orientFactor * (1 - curtainBlock);
// Wall conduction (unchanged from unmanaged model)
const conductionGain = uWall * (Ta - Ti);
const retainedWarmth = Math.max(0, (baseTemp ?? 16) - Ta) * (retainedScale ?? 0.35);
const target = Ta + solarGain * (solarScale ?? 0.1) + retainedWarmth + (internalGain ?? 0.7);
const heatCapProxy = 0.15;
const Ti_instant = Ti + (conductionGain + solarGain) * heatCapProxy;
// Apply thermal lag
Ti = Ti + alpha * (Ti_instant - Ti);
// Apply thermal lag toward the managed target.
Ti = Ti + alpha * (target - Ti);
// Smart ventilation: only open windows when outside is cooler
if (Ta < Ti) {
+13 -7
View File
@@ -166,15 +166,21 @@ export const VEHICLE_TYPES = {
// closed). Thick lined curtains ≈ 0.80; blinds ≈ 0.50.
// ventAlpha — Blending weight per hour when smart ventilation is open.
// Higher = more air changes per hour.
// solarScale — Converts effective window solar gain into an indoor
// temperature lift. Lower values mean more thermal mass.
// baseTemp — Occupied/retained warmth baseline for normal homes.
// internalGain — Small heat gain from people, appliances, and background use.
// retainedScale — How strongly the building holds above-outdoor warmth in
// cool conditions. Higher = better retained warmth.
// ═══════════════════════════════════════════════════════════════════
export const BUILDING_TYPES = {
brick: { name: 'Brick (typical)', uWall: 0.35, lagHours: 4, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.25 },
modern: { name: 'Modern / Well insulated', uWall: 0.18, lagHours: 5, glazingRatio: 0.20, gValue: 0.30, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.20 },
victorian: { name: 'Victorian Terrace', uWall: 0.55, lagHours: 5, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.30 },
stone: { name: 'Stone / Granite Cottage', uWall: 0.45, lagHours: 7, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.25 },
timber: { name: 'Timber Frame / New Build', uWall: 0.22, lagHours: 2, glazingRatio: 0.22, gValue: 0.35, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.35 },
flat: { name: 'Top-floor Flat', uWall: 0.40, lagHours: 3, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.20 },
conservatory:{ name: 'Conservatory / Sun Room', uWall: 1.20, lagHours: 1, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, ventAlpha: 0.50 },
brick: { name: 'Brick (typical)', uWall: 0.35, lagHours: 4, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.25, solarScale: 0.10, baseTemp: 16.5, internalGain: 0.8, retainedScale: 0.35 },
modern: { name: 'Modern / Well insulated', uWall: 0.18, lagHours: 5, glazingRatio: 0.20, gValue: 0.30, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.20, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.55 },
victorian: { name: 'Victorian Terrace', uWall: 0.55, lagHours: 5, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.30, solarScale: 0.10, baseTemp: 16.0, internalGain: 0.7, retainedScale: 0.25 },
stone: { name: 'Stone / Granite Cottage', uWall: 0.45, lagHours: 7, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.25, solarScale: 0.08, baseTemp: 15.5, internalGain: 0.6, retainedScale: 0.40 },
timber: { name: 'Timber Frame / New Build', uWall: 0.22, lagHours: 2, glazingRatio: 0.22, gValue: 0.35, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.35, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.45 },
flat: { name: 'Top-floor Flat', uWall: 0.40, lagHours: 3, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.20, solarScale: 0.11, baseTemp: 17.0, internalGain: 0.9, retainedScale: 0.45 },
conservatory:{ name: 'Conservatory / Sun Room', uWall: 1.20, lagHours: 1, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, ventAlpha: 0.50, solarScale: 0.045, baseTemp: 12.0, internalGain: 0.2, retainedScale: 0.05 },
};
// ═══════════════════════════════════════════════════════════════════