Beta 9
More 'Home' Physics More FAQs More Text
This commit is contained in:
+130
-86
@@ -27,7 +27,7 @@ import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcrete
|
||||
import {
|
||||
utciCategory, precipPenalty, windCompass8, uvSplit,
|
||||
SKIN_TYPES, sunburnMinutes, burnLabel,
|
||||
VEHICLE_TYPES,
|
||||
VEHICLE_TYPES, BUILDING_TYPES,
|
||||
cloudCategory, confidenceBand, moonGlyph,
|
||||
} from './utils.js';
|
||||
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon } from './components.js';
|
||||
@@ -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.
|
||||
@@ -178,6 +178,16 @@ export function UTCIForecast() {
|
||||
|
||||
// Vehicle type for the cabin heat column. 'car' is the default preset.
|
||||
const [vehicleType, setVehicleType] = useState('car');
|
||||
|
||||
// Vehicle ventilation — true = windows open (high convective loss).
|
||||
const [vehicleVent, setVehicleVent] = useState(false);
|
||||
|
||||
// Indoor: buildingType drives the physics preset; indoorManaged toggles the
|
||||
// curtains+ventilation model on top. indoorMode gates column visibility:
|
||||
// 'off' = hidden, 'on' = indoorT shown (managed or not depending on indoorManaged).
|
||||
const [buildingType, setBuildingType] = useState('brick');
|
||||
const [indoorManaged, setIndoorManaged] = useState(false);
|
||||
const [indoorMode, setIndoorMode] = useState('off');
|
||||
const searchTimeout = useRef(null);
|
||||
|
||||
// Refs for the two-scroller table layout (sticky-to-viewport header +
|
||||
@@ -579,7 +589,7 @@ export function UTCIForecast() {
|
||||
// dt kept for SkyScope / backward compat — same as dtUTC.
|
||||
const dt = dtUTC;
|
||||
const elev = solarElevationDeg(location.lat, location.lon, dtUTC);
|
||||
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType);
|
||||
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent);
|
||||
const eh = vaporPressureHpa(Ta, RH);
|
||||
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
|
||||
const utci = utciApprox(Ta, Tmrt, va, eh);
|
||||
@@ -605,8 +615,8 @@ export function UTCIForecast() {
|
||||
const TaArr = hourlyRows.map(r => r.Ta);
|
||||
const globArr = hourlyRows.map(r => r.glob);
|
||||
const elevArr = hourlyRows.map(r => r.elev);
|
||||
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr);
|
||||
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr);
|
||||
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]; });
|
||||
}
|
||||
|
||||
@@ -957,19 +967,48 @@ export function UTCIForecast() {
|
||||
}
|
||||
setActiveProfile(key);
|
||||
if (key !== 'custom') setVisibleCols({ ...profile.cols });
|
||||
// Auto-enable indoor column for profiles that include it;
|
||||
// reset to off for profiles that don't.
|
||||
if (key !== 'custom') {
|
||||
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
||||
setIndoorMode(hasIndoor ? 'on' : 'off');
|
||||
setIndoorManaged(false);
|
||||
}
|
||||
}}
|
||||
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
COLUMN TOGGLES — Pro only. Hidden entirely for free users
|
||||
(the profile selector above provides enough control for them).
|
||||
COLUMN TOGGLES — in exact table column order.
|
||||
Buttons visible to all users when profile includes that col.
|
||||
Burn + Vehicle dropdowns always shown (free + pro).
|
||||
Pro users see all toggles; free users see profile-filtered subset.
|
||||
-->
|
||||
${(FILTER_PROFILES[activeProfile]?.cols['burn'] || FILTER_PROFILES[activeProfile]?.cols['vehicleT']) && html`
|
||||
${(isPro || FILTER_PROFILES[activeProfile]?.cols['burn'] || FILTER_PROFILES[activeProfile]?.cols['vehicleT'] || FILTER_PROFILES[activeProfile]?.cols['indoorT'] || FILTER_PROFILES[activeProfile]?.cols['managedT']) && html`
|
||||
<div class="col-toggles">
|
||||
<span class="col-toggles-label">Columns:</span>
|
||||
${FILTER_PROFILES[activeProfile]?.cols['burn'] && html`
|
||||
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['air']) && html`<button class=${`col-toggle${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['rh']) && html`<button class=${`col-toggle${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dew']) && html`<button class=${`col-toggle${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT']) && html`<button class=${`col-toggle${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT6']) && html`<button class=${`col-toggle${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilM']) && html`<button class=${`col-toggle${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['concreteT']) && html`<button class=${`col-toggle${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['wind']) && html`<button class=${`col-toggle${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dir']) && html`<button class=${`col-toggle${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['cloud']) && html`<button class=${`col-toggle${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['sun']) && html`<button class=${`col-toggle${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['direct']) && html`<button class=${`col-toggle${visibleCols.direct ? ' on' : ''}`} onClick=${() => toggleCol('direct')}>Direct</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['diffuse']) && html`<button class=${`col-toggle${visibleCols.diffuse ? ' on' : ''}`} onClick=${() => toggleCol('diffuse')}>Diffuse</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['tmrt']) && html`<button class=${`col-toggle${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['delta']) && html`<button class=${`col-toggle${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['utci']) && html`<button class=${`col-toggle${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvA']) && html`<button class=${`col-toggle${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvB']) && html`<button class=${`col-toggle${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`}
|
||||
|
||||
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['burn']) && html`
|
||||
<select
|
||||
class=${`col-toggle burn-select${visibleCols.burn ? ' on' : ''}`}
|
||||
value=${visibleCols.burn ? skinType : 'off'}
|
||||
@@ -985,63 +1024,72 @@ export function UTCIForecast() {
|
||||
${Object.entries(SKIN_TYPES).map(([k, v]) => html`
|
||||
<option key=${k} value=${k}>${v.name.split(' · ')[1]} skin</option>`)}
|
||||
</select>`}
|
||||
${FILTER_PROFILES[activeProfile]?.cols['vehicleT'] && html`
|
||||
<select
|
||||
class=${`col-toggle burn-select${visibleCols.vehicleT ? ' on' : ''}`}
|
||||
value=${visibleCols.vehicleT ? vehicleType : 'off'}
|
||||
onChange=${(e) => {
|
||||
if (e.target.value === 'off') {
|
||||
setVisibleCols(prev => ({ ...prev, vehicleT: false }));
|
||||
} else {
|
||||
setVehicleType(e.target.value);
|
||||
setVisibleCols(prev => ({ ...prev, vehicleT: true }));
|
||||
}
|
||||
}}>
|
||||
<option value="off">${visibleCols.vehicleT ? 'Hide Vehicle' : 'Vehicle'}</option>
|
||||
${Object.entries(VEHICLE_TYPES).map(([k, v]) => html`
|
||||
<option key=${k} value=${k}>${v.name}</option>`)}
|
||||
</select>`}
|
||||
</div>`}
|
||||
|
||||
${isPro && html`
|
||||
<div class="col-toggles">
|
||||
<span class="col-toggles-label">Columns:</span>
|
||||
${[
|
||||
// Hour and UTCI+P are always-on — no toggle button for them.
|
||||
{ key: 'air', label: 'Air' },
|
||||
{ key: 'rh', label: 'RH' },
|
||||
{ key: 'dew', label: 'Dew' },
|
||||
{ key: 'soilT', label: 'Soil °C' },
|
||||
{ key: 'soilT6', label: 'Soil 6cm' },
|
||||
{ key: 'soilM', label: 'Soil moist' },
|
||||
{ key: 'concreteT', label: 'Concrete' },
|
||||
{ key: 'indoorT', label: 'Indoors' },
|
||||
{ key: 'managedT', label: 'Managed' },
|
||||
{ key: 'wind', label: 'Wind' },
|
||||
{ key: 'dir', label: 'Dir' },
|
||||
{ key: 'cloud', label: 'Cloud' },
|
||||
{ key: 'sun', label: 'Sun' },
|
||||
{ key: 'direct', label: 'Direct' },
|
||||
{ key: 'diffuse', label: 'Diffuse' },
|
||||
{ key: 'tmrt', label: 'Tmrt' },
|
||||
{ key: 'delta', label: 'Δ' },
|
||||
{ key: 'utci', label: 'UTCI' },
|
||||
{ key: 'uvA', label: 'UV-A' },
|
||||
{ key: 'uvB', label: 'UV-B' },
|
||||
].filter(c =>
|
||||
activeProfile === 'custom' || !!FILTER_PROFILES[activeProfile].cols[c.key]
|
||||
).map(c => html`
|
||||
<button
|
||||
key=${c.key}
|
||||
class=${`col-toggle${visibleCols[c.key] ? ' on' : ''}`}
|
||||
onClick=${() => toggleCol(c.key)}
|
||||
>${c.label}</button>`)}
|
||||
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['vehicleT']) && html`
|
||||
<span class="col-toggle-group">
|
||||
<select
|
||||
class=${`col-toggle burn-select grouped-left${visibleCols.vehicleT ? ' on' : ''}`}
|
||||
value=${visibleCols.vehicleT ? vehicleType : 'off'}
|
||||
onChange=${(e) => {
|
||||
if (e.target.value === 'off') {
|
||||
setVisibleCols(prev => ({ ...prev, vehicleT: false }));
|
||||
setVehicleVent(false);
|
||||
} else {
|
||||
setVehicleType(e.target.value);
|
||||
setVisibleCols(prev => ({ ...prev, vehicleT: true }));
|
||||
}
|
||||
}}>
|
||||
<option value="off">${visibleCols.vehicleT ? 'Hide Vehicle' : 'Vehicle'}</option>
|
||||
${Object.entries(VEHICLE_TYPES).map(([k, v]) => html`
|
||||
<option key=${k} value=${k}>${v.name}</option>`)}
|
||||
</select>
|
||||
${visibleCols.vehicleT && html`
|
||||
<label
|
||||
class=${`col-toggle-vent grouped-right${vehicleVent ? ' on' : ''}`}
|
||||
title="Ventilation — open windows significantly reduce cabin heat build-up">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked=${vehicleVent}
|
||||
onChange=${() => setVehicleVent(v => !v)}
|
||||
style=${{ position: 'absolute', opacity: 0, width: 0, height: 0 }} />
|
||||
<span class="vent-checkbox${vehicleVent ? ' checked' : ''}"></span>
|
||||
Ventilation
|
||||
</label>`}
|
||||
</span>`}
|
||||
|
||||
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['indoorT'] || FILTER_PROFILES[activeProfile].cols['managedT']) && html`
|
||||
<span class="col-toggle-group">
|
||||
<select
|
||||
class=${`col-toggle burn-select grouped-left${indoorMode === 'on' ? ' on' : ''}`}
|
||||
value=${indoorMode === 'on' ? buildingType : 'off'}
|
||||
onChange=${(e) => {
|
||||
if (e.target.value === 'off') {
|
||||
setIndoorMode('off');
|
||||
setIndoorManaged(false);
|
||||
} else {
|
||||
setBuildingType(e.target.value);
|
||||
setIndoorMode('on');
|
||||
}
|
||||
}}>
|
||||
<option value="off">${indoorMode === 'on' ? 'Hide Indoors' : 'Indoors'}</option>
|
||||
${Object.entries(BUILDING_TYPES).map(([k, v]) => html`
|
||||
<option key=${k} value=${k}>${v.name}</option>`)}
|
||||
</select>
|
||||
${indoorMode === 'on' && html`
|
||||
<label
|
||||
class=${`col-toggle-vent grouped-right${indoorManaged ? ' on' : ''}`}
|
||||
title="Managed: curtains closed by day, windows open when cooler outside">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked=${indoorManaged}
|
||||
onChange=${() => setIndoorManaged(v => !v)}
|
||||
style=${{ position: 'absolute', opacity: 0, width: 0, height: 0 }} />
|
||||
<span class="vent-checkbox${indoorManaged ? ' checked' : ''}"></span>
|
||||
Managed
|
||||
</label>`}
|
||||
</span>`}
|
||||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html`<button class=${`col-toggle${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
|
||||
|
||||
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html`
|
||||
<button
|
||||
class=${`col-toggle${visibleCols.precip ? ' on' : ''}`}
|
||||
onClick=${() => toggleCol('precip')}
|
||||
>Precip</button>`}
|
||||
</div>`}
|
||||
|
||||
<!--
|
||||
@@ -1084,8 +1132,8 @@ export function UTCIForecast() {
|
||||
${visibleCols.uvB && html`<th class="col-info-th" onClick=${(e) => handleThClick('uvB', e)}>UV-B <span class="col-unit">est. idx</span></th>`}
|
||||
${visibleCols.burn && html`<th class="col-info-th" onClick=${(e) => handleThClick('burn', e)}>Burn <span class="col-unit">to MED</span></th>`}
|
||||
${visibleCols.vehicleT && html`<th class="col-info-th" onClick=${(e) => handleThClick('vehicleT', e)}>Vehicle <span class="col-unit">°C peak</span></th>`}
|
||||
${visibleCols.indoorT && html`<th class="col-info-th" onClick=${(e) => handleThClick('indoorT', e)}>Indoors <span class="col-unit">°C est.</span></th>`}
|
||||
${visibleCols.managedT && html`<th class="col-info-th" onClick=${(e) => handleThClick('managedT', e)}>Managed <span class="col-unit">°C est.</span></th>`}
|
||||
${indoorMode === 'on' && !indoorManaged && html`<th class="col-info-th" onClick=${(e) => handleThClick('indoorT', e)}>Indoors <span class="col-unit">°C est.</span></th>`}
|
||||
${indoorMode === 'on' && indoorManaged && html`<th class="col-info-th" onClick=${(e) => handleThClick('managedT', e)}>Managed <span class="col-unit">°C est.</span></th>`}
|
||||
${visibleCols.precip && html`<th class="utci-tight-head col-info-th" onClick=${(e) => handleThClick('precip', e)}>Pcpt <span class="col-unit">mm/h</span></th>`}
|
||||
${visibleCols.utciP && html`<th class="col-info-th" onClick=${(e) => handleThClick('utciP', e)}>UTCI+P <span class="col-unit">°C adj.</span></th>`}
|
||||
</tr>
|
||||
@@ -1193,11 +1241,11 @@ export function UTCIForecast() {
|
||||
<td style=${{ color: r.vehicleT != null && r.vehicleT > 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.vehicleT) }}>
|
||||
${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.indoorT && html`
|
||||
${indoorMode === 'on' && !indoorManaged && html`
|
||||
<td style=${{ color: r.indoorT != null && r.indoorT > 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBg(r.indoorT) }}>
|
||||
${r.indoorT != null ? r.indoorT.toFixed(1) : '—'}
|
||||
</td>`}
|
||||
${visibleCols.managedT && html`
|
||||
${indoorMode === 'on' && indoorManaged && html`
|
||||
<td style=${{ color: r.managedT != null && r.managedT > 32 ? '#c0392b' : r.managedT != null && r.managedT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBg(r.managedT) }}>
|
||||
${r.managedT != null ? r.managedT.toFixed(1) : '—'}
|
||||
</td>`}
|
||||
@@ -1264,32 +1312,28 @@ export function UTCIForecast() {
|
||||
<div class="utci-about">
|
||||
<h2 class="utci-about-heading">What is SunScope?</h2>
|
||||
<p class="utci-about-text">
|
||||
SunScope shows how the weather will actually <em>feel</em> on your body — not just the air
|
||||
temperature. It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>, a
|
||||
peer-reviewed biometeorological standard developed by Bröde et al. (2012) that combines
|
||||
air temperature, humidity, wind speed, and solar radiation into a single <em>felt
|
||||
temperature</em>. On a calm, sunny winter day UTCI can read several degrees warmer than
|
||||
the thermometer; on a grey, blustery day it can read far colder. Forecast data is
|
||||
sourced in real time from <strong>Open-Meteo</strong>, a free and open-source weather API,
|
||||
and solar radiation is used to calculate Mean Radiant Temperature — the heat your skin
|
||||
absorbs from the sun — making SunScope one of the most complete outdoor comfort forecasts
|
||||
available for free.
|
||||
SunScope is a free hourly weather forecast built around <strong>felt temperature</strong>,
|
||||
not just air temperature. It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>
|
||||
— the biometeorological standard used in heat-health warning systems worldwide — to combine
|
||||
air temperature, humidity, wind, and solar radiation into a single honest number. The
|
||||
<strong>UTCI+P</strong> column adds an original rain and snow penalty so wet, windy days
|
||||
read as cold as they feel.
|
||||
</p>
|
||||
<p class="utci-about-text">
|
||||
The <strong>UTCI+P</strong> column adds the <strong>SunScope soak-factor</strong>: an
|
||||
original precipitation penalty that accounts for the extra chill of rain and snow on
|
||||
exposed skin and wet clothing. Light drizzle reduces the felt temperature by around
|
||||
1–2 °C; heavy rain combined with wind can push it down by 7–8 °C. Snow carries an
|
||||
additional penalty on top. The result is an honest, real-world comfort score for any
|
||||
location worldwide — simply search for your town or city and compare the hourly
|
||||
forecast across the next 3 days (and up to 14 days with SunScope Extra).
|
||||
Beyond felt temperature, SunScope calculates <strong>vehicle cabin heat</strong> (choose
|
||||
your vehicle type; toggle windows open), <strong>indoor temperature</strong> (seven
|
||||
building types; managed heatwave mode), <strong>urban concrete surface temperature</strong>,
|
||||
<strong>UV index and sunburn time</strong> by skin type, and <strong>soil temperature
|
||||
and moisture</strong> for farming and motorhome use. Switch profiles to see the data
|
||||
that matters for your situation — or go Custom and build your own view.
|
||||
<a href="./about.html" class="utci-about-link">Learn more →</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="utci-footer">
|
||||
<em>Reading the table.</em> A large positive Δ means your body is absorbing
|
||||
far more heat than the air temperature alone suggests — typically due to direct solar radiation.
|
||||
On clear sunny days this gap can exceed 10°C even at modest air temperatures.
|
||||
On clear sunny days this gap can exceed 10 °C even at modest air temperatures.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
+32
-46
@@ -7,13 +7,15 @@
|
||||
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position
|
||||
// calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temp
|
||||
// utciApprox(Ta, Tmrt, va10, ehPa) Bröde et al. 2012 polynomial
|
||||
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType)
|
||||
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated)
|
||||
// calcIndoorTempPass(TaArr, globArr, elevArr, buildingType)
|
||||
// calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType)
|
||||
//
|
||||
// Nothing in here should need editing unless the underlying science
|
||||
// changes. All numbers are peer-reviewed constants or coefficients.
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { VEHICLE_TYPES } from './utils.js';
|
||||
import { VEHICLE_TYPES, BUILDING_TYPES } from './utils.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PHYSICAL CONSTANTS
|
||||
@@ -120,48 +122,37 @@ function _houseHeatLoad(Ta, globalRad, solElev) {
|
||||
|
||||
// Two-pass function: call with the full arrays of hourly Ta and globalRad.
|
||||
// Returns an array of indoor temperatures, one per hour.
|
||||
export function calcIndoorTempPass(TaArr, globArr, elevArr) {
|
||||
// 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 n = TaArr.length;
|
||||
const result = new Array(n);
|
||||
|
||||
// Thermal resistance of the building envelope (°C per W/m² of heat load)
|
||||
// Lower = faster response to outdoor changes. UK brick mid-stock ~0.45.
|
||||
const uWall = 0.35; // W/m²K effective wall U-value
|
||||
// Thermal mass time constant: heavier = longer lag.
|
||||
// ~4 h lag for typical UK brick semi (expressed as exponential decay weight).
|
||||
const lagHours = 4;
|
||||
const alpha = 1 - Math.exp(-1 / lagHours); // per-hour blending weight
|
||||
const alpha = 1 - Math.exp(-1 / lagHours); // per-hour blending weight
|
||||
|
||||
// Seed indoor temp to first outdoor temp
|
||||
let Ti = TaArr[0] ?? 15;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const Ta = TaArr[i] ?? Ti;
|
||||
const glob = globArr[i] ?? 0;
|
||||
const solElev = elevArr[i] ?? 0;
|
||||
const Ta = TaArr[i] ?? Ti;
|
||||
const glob = globArr[i] ?? 0;
|
||||
|
||||
// Solar gain through windows (W/m² effective)
|
||||
const glazingRatio = 0.16;
|
||||
const gValue = 0.63;
|
||||
const orientFactor = 0.50;
|
||||
const solarGain = glob * glazingRatio * gValue * orientFactor;
|
||||
const solarGain = glob * glazingRatio * gValue * orientFactor;
|
||||
|
||||
// Conductive heat flow through walls: proportional to (Ta - Ti)
|
||||
// uWall drives how quickly the indoor temp chases outdoor temp.
|
||||
const conductionGain = uWall * (Ta - Ti);
|
||||
|
||||
// Target indoor temp this hour if there were no thermal mass:
|
||||
// Ti_instant = Ti + conduction + solar load / heat capacity proxy
|
||||
// heatCap proxy: how many degrees does 1 W/m² raise the indoor air?
|
||||
// For a typical 90 m² house, ~0.15 °C per W/m² effective.
|
||||
// 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 slowly
|
||||
// Apply thermal lag: blend toward Ti_instant at the building's time constant
|
||||
Ti = Ti + alpha * (Ti_instant - Ti);
|
||||
|
||||
// Can't be colder than outdoor (house doesn't actively cool)
|
||||
// Cap at 55 °C (physically implausible above this for a house interior)
|
||||
result[i] = Math.max(Math.min(Ti, 55), Math.min(Ta, Ti));
|
||||
}
|
||||
|
||||
@@ -188,20 +179,14 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr) {
|
||||
//
|
||||
// Same thermal lag model as calcIndoorTempPass (4 h brick time constant).
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export function calcManagedIndoorTempPass(TaArr, globArr, elevArr) {
|
||||
// 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 n = TaArr.length;
|
||||
const result = new Array(n);
|
||||
|
||||
const uWall = 0.35;
|
||||
const lagHours = 4;
|
||||
const alpha = 1 - Math.exp(-1 / lagHours);
|
||||
|
||||
// Curtain factor: blocks 80% of solar gain
|
||||
const curtainBlock = 0.80;
|
||||
|
||||
// Ventilation blending weight per hour when windows are open
|
||||
// ~2 ACH for a well-ventilated house → meaningful but not instant pull
|
||||
const ventAlpha = 0.25;
|
||||
const alpha = 1 - Math.exp(-1 / lagHours);
|
||||
|
||||
let Ti = TaArr[0] ?? 15;
|
||||
|
||||
@@ -209,13 +194,10 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr) {
|
||||
const Ta = TaArr[i] ?? Ti;
|
||||
const glob = globArr[i] ?? 0;
|
||||
|
||||
// Solar gain — curtains block 80%
|
||||
const glazingRatio = 0.16;
|
||||
const gValue = 0.63;
|
||||
const orientFactor = 0.50;
|
||||
const solarGain = glob * glazingRatio * gValue * orientFactor * (1 - curtainBlock);
|
||||
// Solar gain — curtains block curtainBlock fraction
|
||||
const solarGain = glob * glazingRatio * gValue * orientFactor * (1 - curtainBlock);
|
||||
|
||||
// Wall conduction (unchanged)
|
||||
// Wall conduction (unchanged from unmanaged model)
|
||||
const conductionGain = uWall * (Ta - Ti);
|
||||
|
||||
const heatCapProxy = 0.15;
|
||||
@@ -265,7 +247,7 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr) {
|
||||
// 35–45 °C — dangerous for children/pets (hyperthermia risk)
|
||||
// > 45 °C — potentially fatal within minutes
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car') {
|
||||
export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car', ventilated = false) {
|
||||
if (globalRad == null || Ta == null) return null;
|
||||
|
||||
// Look up vehicle preset; fall back to a standard car if key unknown.
|
||||
@@ -303,8 +285,12 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'c
|
||||
// ── Combine into cabin air temperature ───────────────────────────
|
||||
// Total heat input per m² of cabin surface
|
||||
const totalGain = conductionGain + glazingGain;
|
||||
// Cabin heat loss: varies by vehicle type (motorhomes insulated, cars not)
|
||||
const Ti = Ta + totalGain / preset.hCabinLoss;
|
||||
// Cabin heat loss: varies by vehicle type (motorhomes insulated, cars not).
|
||||
// With windows open, convective loss is roughly 5× higher — air moves freely
|
||||
// through the cabin, flushing heat out and capping interior temperature much
|
||||
// closer to ambient. Cabin temp still rises a little due to panel/roof solar gain.
|
||||
const effectiveHLoss = ventilated ? preset.hCabinLoss * 5 : preset.hCabinLoss;
|
||||
const Ti = Ta + totalGain / effectiveHLoss;
|
||||
|
||||
// Clamp: can't be cooler than outside air; physical cap at 90 °C
|
||||
return Math.max(Ta, Math.min(Ti, 90));
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// sunburnMinutes(uv, skinType) minutes to MED
|
||||
// burnLabel(mins) formats burn time as "12m"/"1.5h"
|
||||
// VEHICLE_TYPES vehicle presets for cabin heat model
|
||||
// BUILDING_TYPES building presets for indoor heat model
|
||||
// cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast'
|
||||
// confidenceBand(i) day-tab gradient + label
|
||||
// moonPhaseFraction(date) 0..1 synodic phase
|
||||
@@ -140,6 +141,36 @@ export const VEHICLE_TYPES = {
|
||||
caravan: { name: 'Caravan (towed)', albedo: 0.40, glazingArea: 0.4, hCabinLoss: 1.0 },
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// BUILDING TYPES — presets for the indoor temperature model.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Each preset drives both calcIndoorTempPass and calcManagedIndoorTempPass.
|
||||
//
|
||||
// uWall W/m²K Effective envelope U-value. Higher = faster response
|
||||
// to outdoor swings, less insulation.
|
||||
// lagHours h Thermal mass time constant. Heavier construction = longer
|
||||
// lag before indoor temp follows outdoor changes.
|
||||
// glazingRatio — Fraction of floor area that is window. More glass = more
|
||||
// solar gain in summer, more heat loss in winter.
|
||||
// gValue — Solar heat gain coefficient of glazing. 0.63 = standard
|
||||
// double glazing; 0.3 = modern low-e triple.
|
||||
// orientFactor — Fraction of windows facing the sun at any given time.
|
||||
// 0.5 = random orientation; 0.8 = south-facing conservatory.
|
||||
// curtainBlock — Fraction of solar gain blocked when managed (curtains
|
||||
// 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.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
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 },
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// CLOUD CATEGORY — pick one of 4 icon styles from low/mid/high split.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user