diff --git a/assets/css/table.css b/assets/css/table.css
index 0c5a7b0..c498584 100644
--- a/assets/css/table.css
+++ b/assets/css/table.css
@@ -455,6 +455,43 @@
}
.profile-dropdown-close:hover { color: #1e1208; background: rgba(176, 152, 112, 0.16); }
+/* Tablet + desktop: the floating bottom button is mobile-only, so the panel
+ hangs off the bottom edge of the active-profile-row instead — dropping
+ down out of that row and shrinking back into it on close.
+ --profile-anchor-top is the row's live viewport bottom, set by
+ ConfigPanel.js. Phones (<=640px) keep the bottom sheet untouched. */
+@media (min-width: 641px) {
+ .profile-dropdown {
+ top: var(--profile-anchor-top, 0px);
+ bottom: auto;
+ max-height: calc(100vh - var(--profile-anchor-top, 0px) - 16px);
+ border: 1.5px solid #c9b08a;
+ border-radius: 12px;
+ overflow: hidden; /* keeps the scrolling body inside the rounded corners */
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
+ transform-origin: 50% 0;
+ }
+ .profile-dropdown-body {
+ max-height: calc(100vh - var(--profile-anchor-top, 0px) - 16px);
+ }
+
+ /* Too little room under the row for the content — revert to the mobile
+ bottom sheet (set by ConfigPanel.js) so the panel gets the full height. */
+ .profile-dropdown.is-bottom-sheet {
+ top: auto;
+ bottom: 0;
+ max-height: calc(100vh - 24px);
+ border: none;
+ border-top: 1.5px solid #c9b08a;
+ border-radius: 12px 12px 0 0;
+ box-shadow: 0 -10px 28px rgba(0, 0, 0, 0.18);
+ transform-origin: 50% 100%;
+ }
+ .profile-dropdown.is-bottom-sheet .profile-dropdown-body {
+ max-height: calc(100vh - 24px);
+ }
+}
+
/* Read-only "what am I looking at" row above the day tabs — states the
active profile and the thermal basis (SunSoak env / vehicle / building /
fur colour) driving the numbers below. Clicking/Enter opens the same
@@ -465,15 +502,16 @@
justify-content: center;
flex-wrap: wrap;
gap: 12px;
- padding: 2px 16px;
- margin-bottom: 6px;
+ padding: 8px 16px;
+ margin-bottom: 4px;
/* --row-bg is set inline per the exact selected option (see ROW_GRADIENTS
in DayTabs.js) — falls back to a flat parchment tone if unset. */
background: var(--row-bg, #fdf8ee);
- border: 1.5px solid #d4c0a0;
- border-radius: 6px;
+ border: 2px solid #d4c0a0;
+ border-radius: 9px;
cursor: pointer;
- transition: filter 0.15s, border-color 0.15s;
+ box-shadow: inset 0 -1px 0 rgba(0,0,0,0.05), 0 1px 3px rgba(0,0,0,0.04);
+ transition: filter 0.2s, border-color 0.2s, box-shadow 0.2s;
}
/* Remove alpha from the default fallback color and any potential inherited
semi-transparent backgrounds while keeping the hue. */
@@ -494,13 +532,16 @@
}
.active-profile-row:hover,
.active-profile-row:focus-visible {
- filter: brightness(1.06);
+ filter: brightness(1.04);
border-color: #c8922a;
+ box-shadow: inset 0 -1px 0 rgba(0,0,0,0.05), 0 3px 8px rgba(200,146,42,0.18);
outline: none;
}
+
+/* Breathing room between each "PROFILE" / "VIEWING" label and its value. */
.active-profile-row-item {
display: inline-flex;
- align-items: center;
+ align-items: baseline;
gap: 7px;
}
.active-profile-row-label {
@@ -521,12 +562,22 @@
}
.active-profile-row-sep { color: #c0a880; font-size: 16px; }
.active-profile-row-edit {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
font-family: Manrope, sans-serif;
- font-size: 13px;
+ font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.04em;
color: #9a6a1a;
- margin-left: 4px;
+ margin-left: 8px;
+ opacity: 0.7;
+ transition: opacity 0.2s, transform 0.2s, color 0.2s;
+}
+.active-profile-row:hover .active-profile-row-edit,
+.active-profile-row:focus-visible .active-profile-row-edit {
+ opacity: 1;
+ color: #c8922a;
}
/* Border + text accent per thermal-model group, reusing the same colour
diff --git a/assets/js/components/ConfigPanel.js b/assets/js/components/ConfigPanel.js
index 6e65d4e..2630282 100644
--- a/assets/js/components/ConfigPanel.js
+++ b/assets/js/components/ConfigPanel.js
@@ -55,6 +55,7 @@ export function ConfigPanel({
}) {
const profileScrollRef = useRef(null);
const profileWrapRef = useRef(null);
+ const dropdownRef = useRef(null);
const { mainConfigKey, mainLabel } = deriveProfileMain(activeProfile, outdoorsVariant);
@@ -77,6 +78,34 @@ export function ConfigPanel({
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
+ // From tablet up the panel drops *out of* the active-profile-row rather
+ // than sliding up from the floating button (which is mobile-only), so it
+ // has to be anchored to that row's live bottom edge — it's sticky on
+ // desktop and scrolls away on tablet, hence remeasuring on scroll/resize.
+ // Mobile (<=640px) ignores the var entirely and keeps the bottom sheet.
+ useEffect(() => {
+ const el = dropdownRef.current;
+ if (!el) return;
+ const measure = () => {
+ const row = document.querySelector('.active-profile-row');
+ const top = row ? Math.max(0, row.getBoundingClientRect().bottom) : 0;
+ el.style.setProperty('--profile-anchor-top', `${Math.round(top)}px`);
+ // Not enough room below the row for the panel's content? Fall back to
+ // the mobile bottom-sheet layout, which gets the full viewport height.
+ const body = el.querySelector('.profile-dropdown-body');
+ const content = body ? body.scrollHeight : 0;
+ el.classList.toggle('is-bottom-sheet', content > window.innerHeight - top - 16);
+ };
+ measure();
+ if (!open) return;
+ window.addEventListener('scroll', measure, true);
+ window.addEventListener('resize', measure);
+ return () => {
+ window.removeEventListener('scroll', measure, true);
+ window.removeEventListener('resize', measure);
+ };
+ }, [open, activeTab, activeProfile, outdoorsVariant]);
+
// Drag-to-scroll + fade edges for the horizontal profile card row.
useEffect(() => {
const el = profileScrollRef.current;
@@ -226,7 +255,7 @@ export function ConfigPanel({
return html`
<${Fragment}>
-
+
diff --git a/assets/js/components/DayTabs.js b/assets/js/components/DayTabs.js
index 76fc8bc..0578b1d 100644
--- a/assets/js/components/DayTabs.js
+++ b/assets/js/components/DayTabs.js
@@ -35,7 +35,7 @@ import { h, Fragment } from '../../vendor/preact.js';
import htm from '../../vendor/htm.js';
import { confidenceBand, utciCategory, VEHICLE_SPEEDS, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js';
-import { calcVehicleInteriorTemp } from '../physics.js';
+import { calcVehicleInteriorTempPass } from '../physics.js';
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js';
import { SubscribeModal } from './SubscribeModal.js';
@@ -222,9 +222,12 @@ export function DayTabs({
let shadeHi = null, shadeLo = null;
if (isVehicleProfile) {
const speedMph = (VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).mph;
- const ventVals = d.rows
- .map(r => calcVehicleInteriorTemp(r.Ta, r.glob, r.elev, vehicleType, true, speedMph))
- .filter(v => isFinite(v));
+ // Run the lagged pass over the whole day, not per-hour equilibrium,
+ // so this hi/lo is directly comparable with the vehicleT column.
+ const ventVals = calcVehicleInteriorTempPass(
+ d.rows.map(r => r.Ta), d.rows.map(r => r.glob), d.rows.map(r => r.elev),
+ d.rows.map(r => r.va), vehicleType, true, speedMph
+ ).filter(v => v != null && isFinite(v));
shadeHi = ventVals.length ? Math.round(Math.max(...ventVals)) : null;
shadeLo = ventVals.length ? Math.round(Math.min(...ventVals)) : null;
} else if (secondaryField) {
diff --git a/assets/js/components/SubscribeModal.js b/assets/js/components/SubscribeModal.js
index f15199d..a834113 100644
--- a/assets/js/components/SubscribeModal.js
+++ b/assets/js/components/SubscribeModal.js
@@ -23,13 +23,7 @@ import { Wordmark } from './Wordmark.js';
const html = htm.bind(h);
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
-// TODO: replace with the one-time Payment Link created in the Stripe
-// Dashboard (see plan Part 3) - a flat-rate product with a few selectable
-// price options (e.g. £3 / £5 / £10), since Stripe Payment Links don't
-// support true customer-chosen amounts. Configure its after-payment
-// redirect to https://sunscope.net/?session_id={CHECKOUT_SESSION_ID}
-// the same way the monthly link should be.
-const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/REPLACE_WITH_ONEOFF_PAYMENT_LINK';
+const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/14A14odTJ6bT0jY4jwd7q02';
const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
export function SubscribeModal({ title, detail, onClose, openRestore }) {
diff --git a/assets/js/compute.js b/assets/js/compute.js
index 4f801b1..adb623e 100644
--- a/assets/js/compute.js
+++ b/assets/js/compute.js
@@ -13,7 +13,7 @@
import {
vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox,
- calcConcreteTempPass, calcVehicleInteriorTemp,
+ calcConcreteTempPass, calcVehicleInteriorTempPass,
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp,
calcFurSurfaceTempPass,
} from './physics.js';
@@ -88,7 +88,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
// (building shadow, beach umbrella, canopy...). Shares SunSoak's TaEnv
// baseline and env-reduced radiation, so the two columns stay consistent.
const shadeT = calcShadeAirTemp(TaEnv, dirEnv + difEnv * 0.2, va, elev, env.shade);
- const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent, vehicleSpeed);
+ // vehicleT is stamped in the two-pass section below (thermal lag).
const eh = vaporPressureHpa(Ta, RH);
const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev);
const utci = utciApprox(TaEnv, Tmrt, va, eh);
@@ -153,7 +153,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
cc, ccLow, ccMid, ccHigh, cloudCat,
uv, uvA, uvB,
precip, precipProb, lightning, cape, snow,
- soilT0, soilT6, soilM, vehicleT, shadeT,
+ soilT0, soilT6, soilM, shadeT,
effectiveRad,
elev, Tmrt, utci, utciAdj, eh, compass,
visKm, aqi,
@@ -161,8 +161,8 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
};
}) : [];
- // Two-pass calculations: concrete thermal lag + indoor temperature.
- // Both need the full hourly arrays so they can look back at previous
+ // Two-pass calculations: concrete thermal lag + indoor + vehicle cabin.
+ // All need the full hourly arrays so they can look back at previous
// hours. Run after hourlyRows is built, then stamp each row.
if (hourlyRows.length > 0) {
const TaArr = hourlyRows.map(r => r.Ta);
@@ -188,6 +188,15 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType);
hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; });
+ // Vehicle cabin temperature with thermal lag. A parked vehicle climbs
+ // toward the hour's equilibrium rather than jumping to it, so a van that
+ // has been in the sun since morning reads hotter than the same van an
+ // hour after parking. vaArr feeds ambient wind into shell convection.
+ const vehicleTemps = calcVehicleInteriorTempPass(
+ TaArr, globArr, elevArr, vaArr, vehicleType, vehicleVent, vehicleSpeed
+ );
+ hourlyRows.forEach((r, i) => { r.vehicleT = vehicleTemps[i]; });
+
// Fur surface temperature with thermal lag - Pets profile.
const furAlbedo = (FUR_COLORS[furColor] || FUR_COLORS.brown).albedo;
const furTemps = calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo);
diff --git a/assets/js/config.js b/assets/js/config.js
index 493c2ab..cf881f3 100644
--- a/assets/js/config.js
+++ b/assets/js/config.js
@@ -256,7 +256,7 @@ export const COL_DESCRIPTIONS = {
soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' },
soilM: { title: 'Soil Moisture', desc: 'Water saturation of the top 1 cm of soil as a percentage. Above 40% suggests saturated ground; below 20% 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 15–25 °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.' },
+ 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 that warm more slowly but hold heat longer, with gain through the windscreen and rooflights and a little retained living-space warmth. Accounts for ambient wind over the bodywork, road speed, and whether the windows are open. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' },
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.' },
vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.' },
diff --git a/assets/js/physics.js b/assets/js/physics.js
index edb907e..257ef24 100644
--- a/assets/js/physics.js
+++ b/assets/js/physics.js
@@ -11,7 +11,8 @@
// 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)
+// 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
// vaporPressureHpa(Ta, RH) Magnus formula - hPa
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position (-)
@@ -375,92 +376,115 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType
// fleet; dark paint ~0.10, silver/white ~0.40).
// Panel surface temp - conductive gain into cabin air.
//
-// 2. SIDE-WINDOW GLAZING GAIN (sun-angle dependent)
+// 2. VERTICAL GLAZING GAIN (sun-angle dependent)
// When solar elevation is between ~10- and ~60-, the sun's rays
-// cut through the side glass at an angle that allows significant
-// transmission into the cabin (rather than hitting the roof or
-// reflecting off at a shallow angle). This warms the cabin air
+// cut through the side glass and windscreen at an angle that allows
+// significant transmission into the cabin. This 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.
-// Above 60- the sun mostly hits the roof; below 10- it reflects.
+// Outside that window a diffuse floor still applies: scattered sky
+// light enters the glass at any sun angle, so the tent function is
+// clamped rather than switched off (an unclamped tent produced a
+// discontinuity where a LOWER sun gave MORE glazing gain).
//
-// Wind is ignored unless ventilation is enabled. No evaporative cooling.
+// 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
-// and the interior response is slower than a car cabin. Because they are
-// occupied living spaces, they also retain warmth from previous hours, people,
-// appliances, and background heating; without that, cool sunny days are
-// under-estimated badly.
+// 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) {
+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) and reproduces the
- // original stationary model exactly; higher speeds scrub the shell with
- // forced airflow and (windows down) flush the cabin toward ambient.
+ // 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.
- // Parked, a light breeze over the panels gives hOut ~10 W/m-K. Once moving,
- // forced convection rises with road speed (same sqrt form as the surface
- // model), so the bodywork runs progressively closer to ambient.
- const hOut = 10 + 5.5 * Math.sqrt(vMs);
+ // 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. Side-window glazing gain (angle-dependent) -----------------
+ // -- 2. Vertical glazing gain (angle-dependent) --------------------
// Glazing transmission for auto glass ~0.70; scaled by vehicle glazing area.
+ // Tent function peaks at 35- elevation, where the sun cuts squarely through
+ // side glass and windscreen, and tapers either side. It is clamped at 0.30
+ // (= the 0.15 diffuse floor once the 0.5 not-in-beam factor is applied) so
+ // scattered sky light always gets in. Without that clamp the gain fell to
+ // zero at 10- and 60- and then jumped back up outside the range.
const tau = 0.70 * preset.glazingArea;
- let glazingGain = 0;
- if (solElev != null && solElev > 10 && solElev < 60) {
- // Scale factor: peaks around 30-40- elevation (sun cuts squarely
- // through side glass), tapers off toward 10- (shallow/reflected)
- // and 60- (sun increasingly hitting roof not side glass).
- // Use a simple tent function peaking at 35-.
- const peak = 35;
- const halfWidth = 25; // degrees either side
- const factor = Math.max(0, 1 - Math.abs(solElev - peak) / halfWidth);
- // Diffuse radiation also enters through glass regardless of angle
- glazingGain = tau * globalRad * factor * 0.5; // occupant not in beam - 50% ambient
- } else {
- // Outside the side-window zone: diffuse only (scattered sky light)
- glazingGain = tau * (globalRad * 0.15); // ~15% diffuse fraction
- }
+ const tent = (solElev != null && solElev > 0)
+ ? Math.max(0, 1 - Math.abs(solElev - 35) / 25)
+ : 0;
+ const glazingGain = tau * globalRad * Math.max(0.30, tent) * 0.5; // occupant not in beam
+
+ // -- 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;
+ const totalGain = conductionGain + glazingGain + roofGain;
// Cabin heat rejection: an effective blend of leakage, internal air volume,
- // and surfaces exchanging heat with the outside. With windows open it is
- // roughly 5x higher when parked - air moves freely through the cabin,
- // flushing heat out and capping interior temperature much closer to ambient.
+ // 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 ? 5 * speedFactor : 1 + vMs / 40;
+ const lossMult = ventilated ? (preset.ventMult ?? 4) * speedFactor : 1 + vMs / 40;
const effectiveHLoss = preset.hCabinLoss * lossMult;
- const thermalMass = preset.thermalMass ?? 1;
- const solarRise = (totalGain / effectiveHLoss) * thermalMass;
+ 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, 8 - 0.25 * Ta) * (ventilated ? 0.35 : 1)
+ ? Math.max(0, 5 - 0.20 * Ta) * (ventilated ? 0.35 : 1)
: 0;
const internalGain = (preset.internalGain ?? 0) * (ventilated ? 0.35 : 1);
@@ -470,6 +494,57 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = '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)
// -------------------------------------------------------------------
@@ -576,7 +651,8 @@ export function calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo
// 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 reproduces the static model.
+// 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
diff --git a/assets/js/utils.js b/assets/js/utils.js
index cb462da..3d2b4c7 100644
--- a/assets/js/utils.js
+++ b/assets/js/utils.js
@@ -209,23 +209,37 @@ export function burnLabel(mins) {
// -------------------------------------------------------------------
// Each entry tweaks the physical levers in calcVehicleInteriorTemp:
// albedo - how much solar the bodywork reflects (0 = black, 1 = mirror)
-// glazingArea - relative sun-exposed glass area (1.0 = typical car)
+// glazingArea - relative VERTICAL glass area (1.0 = typical car). A coachbuilt
+// motorhome has a huge near-vertical windscreen plus cab side
+// windows, so it is far from the 0.25 once assumed here.
+// roofGlazing - relative HORIZONTAL glass area (rooflights / Heki hatches /
+// panoramic glass roof). Gains from these peak at high sun,
+// which is exactly when the vertical-glass term is tailing off.
// bodyU - effective body/panel conductance into the cabin. Cars are
// thin metal/glass boxes; motorhomes/caravans have insulated
// sandwich panels, commonly around 25-35 mm thick.
// hCabinLoss - effective heat rejection/infiltration from the cabin air.
-// thermalMass - lower values mean the interior warms more slowly in the hour.
+// Motorhomes/caravans reject heat more SLOWLY than cars (better
+// sealed, smaller aperture per unit volume), which is why a
+// closed-up van gets as hot as a car despite its insulation.
+// ventMult - how much opening the windows multiplies hCabinLoss when
+// parked. A car with all windows down flushes far more
+// effectively per unit volume than a van with two windows and
+// a rooflight open, so this is not a shared constant.
+// lagHours - interior thermal time constant. This is a DELAY on reaching
+// the hour's equilibrium, not a reduction of it - see the note
+// in calcVehicleInteriorTempPass.
// retainedWarmth - occupied insulated living spaces hold heat from previous
// hours, people, appliances, and background heating.
// internalGain - small living-space warmth boost when closed up.
// -------------------------------------------------------------------
export const VEHICLE_TYPES = {
- car: { name: 'Car / Hatchback', albedo: 0.25, glazingArea: 1.0, bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 },
- mpv: { name: 'MPV / People Carrier', albedo: 0.25, glazingArea: 1.3, bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 },
- suv: { name: 'SUV / 4x4', albedo: 0.22, glazingArea: 1.1, bodyU: 3.8, hCabinLoss: 19, thermalMass: 0.95, retainedWarmth: false, internalGain: 0.0 },
- truck: { name: 'Truck / HGV Cab', albedo: 0.30, glazingArea: 1.2, bodyU: 3.5, hCabinLoss: 18, thermalMass: 0.90, retainedWarmth: false, internalGain: 0.0 },
- motorhome: { name: 'Motorhome / Campervan', albedo: 0.55, glazingArea: 0.25, bodyU: 0.9, hCabinLoss: 12, thermalMass: 0.35, retainedWarmth: true, internalGain: 1.2 },
- caravan: { name: 'Caravan (towed)', albedo: 0.60, glazingArea: 0.22, bodyU: 0.8, hCabinLoss: 11, thermalMass: 0.35, retainedWarmth: true, internalGain: 1.2 },
+ car: { name: 'Car / Hatchback', albedo: 0.25, glazingArea: 1.00, roofGlazing: 0.00, bodyU: 4.0, hCabinLoss: 11.0, ventMult: 4.0, lagHours: 0.5, retainedWarmth: false, internalGain: 0.0 },
+ mpv: { name: 'MPV / People Carrier', albedo: 0.25, glazingArea: 1.30, roofGlazing: 0.02, bodyU: 4.0, hCabinLoss: 11.5, ventMult: 4.0, lagHours: 0.6, retainedWarmth: false, internalGain: 0.0 },
+ suv: { name: 'SUV / 4x4', albedo: 0.22, glazingArea: 1.10, roofGlazing: 0.02, bodyU: 3.8, hCabinLoss: 11.0, ventMult: 4.0, lagHours: 0.6, retainedWarmth: false, internalGain: 0.0 },
+ truck: { name: 'Truck / HGV Cab', albedo: 0.30, glazingArea: 1.20, roofGlazing: 0.00, bodyU: 3.5, hCabinLoss: 10.5, ventMult: 4.0, lagHours: 0.7, retainedWarmth: false, internalGain: 0.0 },
+ motorhome: { name: 'Motorhome / Campervan', albedo: 0.55, glazingArea: 0.85, roofGlazing: 0.06, bodyU: 0.9, hCabinLoss: 6.5, ventMult: 2.5, lagHours: 1.5, retainedWarmth: true, internalGain: 1.2 },
+ caravan: { name: 'Caravan (towed)', albedo: 0.60, glazingArea: 0.55, roofGlazing: 0.07, bodyU: 0.8, hCabinLoss: 6.2, ventMult: 2.4, lagHours: 1.7, retainedWarmth: true, internalGain: 1.2 },
};
// -------------------------------------------------------------------
diff --git a/build.js b/build.js
index f55de72..e3b9ea2 100644
--- a/build.js
+++ b/build.js
@@ -232,7 +232,8 @@ async function build() {
// 8. Copy static files
console.log(' Copying static files...');
- for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php']) {
+ for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php',
+ 'verify-session.php', 'check-subscription.php', 'dev-unlock.php', 'secrets.local.php']) {
const src = path.join(ROOT, f);
if (fs.existsSync(src)) {
copyFile(src, path.join(DIST, f));
diff --git a/data/tracking.json b/data/tracking.json
index b90dfb0..7fdb78a 100644
--- a/data/tracking.json
+++ b/data/tracking.json
@@ -158,5 +158,18 @@
"alltemps": 2,
"showall": 2
}
+ },
+ "2026-07-25": {
+ "visits": 7,
+ "profiles": {
+ "home": 10,
+ "vehicle": 7,
+ "pets": 5,
+ "basic": 5,
+ "alltemps": 3,
+ "showall": 1,
+ "custom": 1,
+ "farming": 1
+ }
}
}
\ No newline at end of file