From efcf2d621cfc7c0fe8876d74a9a90242ff5d172e Mon Sep 17 00:00:00 2001 From: Fraxle Date: Sat, 27 Jun 2026 06:19:25 +0100 Subject: [PATCH] 3.1.0.0 Vehicle Speed Calcs Extra At A Glance ranges New Driver profile --- assets/css/table.css | 4 ++ assets/css/ui.css | 1 + assets/js/app.js | 41 +++++++++++--- assets/js/compute.js | 97 +++++++++++++++++++++++----------- assets/js/config.js | 5 +- assets/js/hooks/useAppState.js | 21 +++++++- assets/js/physics.js | 58 +++++++++----------- assets/js/utils.js | 16 ++++++ data/tracking.json | 7 +++ 9 files changed, 177 insertions(+), 73 deletions(-) diff --git a/assets/css/table.css b/assets/css/table.css index b66ace0..2cb6714 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -1214,6 +1214,10 @@ .col-toggle-group--expanded { flex: 0 0 calc((100% - 12px) / 3 * 2 + 6px); } + /* Vehicle group fuses three children (select + speed + vent) — full row. */ + .col-toggle-group--expanded.col-toggle-group--triple { + flex: 0 0 100%; + } .col-toggle-group .cs-wrap { flex: 1 1 0; min-width: 0; diff --git a/assets/css/ui.css b/assets/css/ui.css index abe16d2..b3a184b 100644 --- a/assets/css/ui.css +++ b/assets/css/ui.css @@ -1682,6 +1682,7 @@ color: #4a3420; text-align: right; flex-shrink: 0; + white-space: pre-line; /* honour \n so multi-range windows stack one per line */ } /* Alert state - draws attention without being aggressive */ diff --git a/assets/js/app.js b/assets/js/app.js index 3d3c934..29ec206 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -26,7 +26,7 @@ import htm from '../vendor/htm.js'; import { utciCategory, UTCI_BANDS, bandGradient, SKIN_TYPES, sunburnMinutes, burnLabel, - VEHICLE_TYPES, BUILDING_TYPES, + VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES, confidenceBand, moonGlyph, skyFillForElev, } from './utils.js'; import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js'; @@ -190,6 +190,7 @@ export function UTCIForecast() { skinType, setSkinType, vehicleType, setVehicleType, vehicleVent, setVehicleVent, + vehicleSpeed, setVehicleSpeed, outdoorsVariant, setOutdoorsVariantAndSave, buildingType, setBuildingType, indoorManaged, setIndoorManaged, @@ -451,6 +452,7 @@ export function UTCIForecast() { outdoorsVariant, skinType, visibleCols, + vehicleSpeed, ); // Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026". @@ -875,7 +877,7 @@ export function UTCIForecast() { /> `} ${showVehicle && html` - + <${CustomSelect} value=${vehicleType} isOn=${vehicleOn} grpClass="grp-felt" hideLabel="Vehicle" hidingLabel="Vehicle" @@ -887,9 +889,16 @@ export function UTCIForecast() { }} /> ${vehicleOn && html` + <${CustomSelect} + value=${vehicleSpeed} isOn=${true} noHide=${true} grpClass="grp-felt" + buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name} + groupedLeft=${true} isLastChild=${false} + options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))} + onChange=${v => setVehicleSpeed(v)} + /> <${VentPill} checked=${vehicleVent} onChange=${() => setVehicleVent(v => !v)} grpClass="grp-felt" label="Ventilation" - title="Open windows significantly reduce cabin heat build-up" />`} + title="Open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion." />`} `} ${showIndoor && html` @@ -941,7 +950,7 @@ export function UTCIForecast() { /> `} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html` - + <${CustomSelect} value=${vehicleType} isOn=${visibleCols.vehicleT} @@ -965,12 +974,23 @@ export function UTCIForecast() { }} /> ${visibleCols.vehicleT && html` + <${CustomSelect} + value=${vehicleSpeed} + isOn=${true} + noHide=${true} + grpClass="grp-felt" + buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name} + groupedLeft=${true} + isLastChild=${false} + options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))} + onChange=${(v) => setVehicleSpeed(v)} + /> <${VentPill} checked=${vehicleVent} onChange=${() => setVehicleVent(v => !v)} grpClass="grp-felt" label="Ventilation" - title="Ventilation — open windows significantly reduce cabin heat build-up" + title="Ventilation — open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion." />`} `} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html` @@ -1562,15 +1582,20 @@ export function UTCIForecast() { ${label} ${value} `; - const comfortItem = glanceSummary.find(i => i.label === 'Comfortable window'); - const restSummary = glanceSummary.filter(i => i.label !== 'Comfortable window'); + // Only the outdoors profile reorders items around the heat block + // (it has a 'Peak felt temp' anchor). Other profiles render in order. + const hasFeltAnchor = glanceSummary.some(i => i.label === 'Peak felt temp'); + const comfortItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Comfortable window') : null; + const drivingItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Good driving time') : null; + const restSummary = glanceSummary.filter(i => !(hasFeltAnchor && (i.label === 'Comfortable window' || i.label === 'Good driving time'))); return [ ...restSummary.flatMap(item => [ renderRow(item), - // After the peak/heat block, drop in the comfortable window + // After the peak/heat block, drop in good driving time then the comfortable window ...(item.label === 'Peak felt temp' ? [ ...heatEvents.map(e => renderRow(e, 'ev-')), + ...(drivingItem ? [renderRow(drivingItem)] : []), ...(comfortItem ? [renderRow(comfortItem)] : []), ] : []), diff --git a/assets/js/compute.js b/assets/js/compute.js index 2ad3bc4..eedc52c 100644 --- a/assets/js/compute.js +++ b/assets/js/compute.js @@ -2,7 +2,7 @@ // compute.js - Build the per-hour display rows from the raw API data. // // Pure-ish function: feed in (forecast, airQuality, location, vehicleType, -// vehicleVent, buildingType) and get back { hourlyRows, days, utcOffsetMs }. +// vehicleVent, vehicleSpeed, buildingType) and get back { hourlyRows, days, utcOffsetMs }. // // Open-Meteo with timezone=auto returns local wall-clock strings like // "2026-05-13T14:00" - no Z suffix. Two forms are used in each row: @@ -18,7 +18,7 @@ import { import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel } from './utils.js'; import { UTCI_ENVIRONMENTS } from './config.js'; -export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType, utciEnv }) { +export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, utciEnv }) { const env = UTCI_ENVIRONMENTS[utciEnv] ?? UTCI_ENVIRONMENTS.open; const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000; @@ -77,7 +77,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v // a surface as direct beam, so we weight it accordingly. const effectiveRad = dir + dif * 0.2; // concreteT is now stamped in the two-pass section below (thermal lag). - const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent); + const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent, vehicleSpeed); const eh = vaporPressureHpa(Ta, RH); // Apply environment modifier - adjust solar inputs and air temp for shaded environments. const TaEnv = Ta + env.taOffset; @@ -377,7 +377,7 @@ export function computeWhyFeelsLike(row, env) { // variant - active sub-variant key e.g. "running", "beach" (or null) // skinType - Fitzpatrick skin type key for UV burn time // ------------------------------------------------------------------------ -export function computeGlanceSummary(todayRows, profile, variant, skinType, cols = null) { +export function computeGlanceSummary(todayRows, profile, variant, skinType, cols = null, vehicleSpeed = 'static') { if (!todayRows || todayRows.length === 0) return []; const show = (key) => !cols || !!cols[key]; @@ -406,18 +406,35 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols (r.precipProb != null && (best == null || r.precipProb > best.precipProb)) ? r : best, null); const maxRainProb = peakRainRow ? (peakRainRow.precipProb ?? 0) : 0; - // Longest run of rows that satisfy a predicate. - const longestWindow = (rows, cond) => { - let best = null, cur = null; + // All contiguous runs of rows that satisfy a predicate. Each run is + // { start, end, len }. Used so an "at a glance" window can report more than + // one range - e.g. good driving in the morning AND again in the evening. + const allWindows = (rows, cond) => { + const out = []; + let cur = null; for (const r of rows) { if (cond(r)) { cur = cur ? { start: cur.start, end: r, len: cur.len + 1 } : { start: r, end: r, len: 1 }; - if (!best || cur.len > best.len) best = { ...cur }; - } else { - cur = null; + } else if (cur) { + out.push(cur); cur = null; } } - return best; + if (cur) out.push(cur); + return out; + }; + + // Format the most significant windows as "h – h", one range per line, in + // chronological order. Caps at `max` ranges (longest kept) so a split day + // reads cleanly. Returns null when there are no windows. The newline is + // preserved by `white-space: pre-line` on .insight-value. + const formatWindows = (wins, max = 2) => { + if (!wins || wins.length === 0) return null; + return [...wins] + .sort((a, b) => b.len - a.len) + .slice(0, max) + .sort((a, b) => (a.start.iso < b.start.iso ? -1 : 1)) + .map(w => `${hhmm(w.start.iso)} – ${hhmmEnd(w.end.iso)}`) + .join('\n'); }; const pollenLabel = (v) => { @@ -470,9 +487,35 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols alert: maxRainProb >= 60, }; + // ── Good Driving Time ────────────────────────────────────────────────── + // Shown whenever a road speed (not Static) is selected and the vehicle cabin + // column is active - so it appears in both the Vehicle and Driver profiles. + // Longest run of hours, across the full day since lorries run day and night, + // that are NOT poor driving conditions: a hot cabin (>29 °C), heavy rain, + // snow/ice, fog/thick mist, or gale-force wind. + const GALE_MS = 39 / 2.237; // 39 mph gust = Gale (Force 8) + const drivingShown = show('vehicleT') && vehicleSpeed !== 'static'; + const drivingWins = drivingShown + ? allWindows(todayRows, r => { + const gustMs = r.gust ?? r.va; + return (r.vehicleT == null || r.vehicleT <= 29) && // cabin not dangerously hot + (r.precip == null || r.precip < 4) && // not heavy rain (mm/h) + (r.snow == null || r.snow === 0) && // no snow + (r.Ta == null || r.Ta > 1) && // no ice risk + (r.visKm == null || r.visKm >= 4) && // not fog / thick mist + (gustMs == null || gustMs < GALE_MS); // not gale-force wind + }) + : []; + const drivingItem = { + icon: '🚚', + label: 'Good driving time', + value: formatWindows(drivingWins) ?? 'Drive with care', + alert: drivingWins.length === 0, + }; + // ── Farming ──────────────────────────────────────────────────────────── if (profile === 'farming') { - const fieldWindow = longestWindow(dayRows, r => + const fieldWins = allWindows(dayRows, r => r.utciAdj >= 8 && r.utciAdj <= 32 && r.precipProb < 30 && r.va < 12 ); const soilWarmRow = todayRows.find(r => r.soilT0 != null && r.soilT0 >= 10); @@ -483,10 +526,8 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols { icon: '⏱', label: 'Best field work', - value: fieldWindow - ? `${hhmm(fieldWindow.start.iso)} – ${hhmmEnd(fieldWindow.end.iso)}` - : 'No suitable window', - alert: !fieldWindow, + value: formatWindows(fieldWins) ?? 'No suitable window', + alert: fieldWins.length === 0, }, ...(show('soilT') ? [{ icon: '🌱', @@ -530,6 +571,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols }] : []), ...(show('precipProb') ? [rainItem] : []), ...lightningItem, + ...(drivingShown ? [drivingItem] : []), { icon: '🌤', label: 'Best travel comfort', @@ -547,7 +589,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols if (profile === 'home') { const peakIndoor = peakRow('indoorT'); const peakManaged = peakRow('managedT'); - const ventWindow = longestWindow(todayRows, r => + const ventWins = allWindows(todayRows, r => r.Ta != null && r.indoorT != null && r.Ta < r.indoorT && r.precipProb < 20 ); const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0)); @@ -569,9 +611,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols ...(show('indoorT') ? [{ icon: '🪟', label: 'Open windows', - value: ventWindow - ? `${hhmm(ventWindow.start.iso)} – ${hhmmEnd(ventWindow.end.iso)}` - : 'Keep closed', + value: formatWindows(ventWins) ?? 'Keep closed', alert: false, }] : []), ...lightningItem, @@ -592,7 +632,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols // ── Activities - running / cycling ───────────────────────────────────── if (variant === 'running' || variant === 'cycling') { - const coolWindow = longestWindow(dayRows, r => + const coolWins = allWindows(dayRows, r => r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30 ); const peakUvRow = peakRow('uv'); @@ -606,10 +646,8 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols { icon: '⏱', label: `Best ${variant} window`, - value: coolWindow - ? `${hhmm(coolWindow.start.iso)} – ${hhmmEnd(coolWindow.end.iso)}` - : 'No cool window today', - alert: !coolWindow, + value: formatWindows(coolWins) ?? 'No cool window today', + alert: coolWins.length === 0, }, ...(show('precipProb') ? [rainItem] : []), ...lightningItem, @@ -635,7 +673,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols } // ── Outdoors - beach, park, events etc. (and fallback) ───────────────── - const comfortWindow = longestWindow(dayRows, r => + const comfortWins = allWindows(dayRows, r => r.utciAdj >= 9 && r.utciAdj <= 26 && r.precipProb < 30 ); const peakFelt = peakRow('utciAdj'); @@ -647,13 +685,12 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols : null; return [ + ...(drivingShown ? [drivingItem] : []), { icon: '🌤', label: 'Comfortable window', - value: comfortWindow - ? `${hhmm(comfortWindow.start.iso)} – ${hhmmEnd(comfortWindow.end.iso)}` - : 'No comfortable window', - alert: !comfortWindow, + value: formatWindows(comfortWins) ?? 'No comfortable window', + alert: comfortWins.length === 0, }, { icon: '🌡', diff --git a/assets/js/config.js b/assets/js/config.js index e2764cb..b90c245 100644 --- a/assets/js/config.js +++ b/assets/js/config.js @@ -108,6 +108,7 @@ export const OUTDOORS_VARIANTS = { market: { name: 'Market Trading', proOnly: true, scene: "url('assets/images/profiles/market.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, windowcleaning: { name: 'Window Cleaning', proOnly: true, scene: "url('assets/images/profiles/window.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, office: { name: 'Office', proOnly: true, scene: "url('assets/images/profiles/office.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, rh: true, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: true, vis: false, aqi: true, pollen: false } }, + driver: { name: 'Driver / Trucker', proOnly: true, scene: "url('assets/images/profiles/vehicle.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, }; // Pollen type options shown in the pulldown selector. @@ -158,6 +159,7 @@ export const VARIANT_DEFAULT_ENV = { construction:'urban', office: 'urban', windowcleaning:'urban', + driver: 'urban', }; // Left-to-right order of the profile buttons in the top filter bar. @@ -185,6 +187,7 @@ export const variantIcons = { market: '🏪', windowcleaning: '🪟', office: '🏢', + driver: '🚚', }; // Which variant keys appear in the Activities dropdown. @@ -194,7 +197,7 @@ export const activityVariantKeys = ['dogwalk', 'running', 'cycling', 'hiking', ' export const placeVariantKeys = ['urban', 'park', 'forest', 'beach', 'events', 'festival', 'airport']; // Which variant keys appear in the Work dropdown. -export const workVariantKeys = ['farming', 'construction', 'market', 'windowcleaning', 'office']; +export const workVariantKeys = ['farming', 'construction', 'market', 'windowcleaning', 'driver', 'office']; // Tooltip text shown when the user clicks/hovers a table column header. export const COL_DESCRIPTIONS = { diff --git a/assets/js/hooks/useAppState.js b/assets/js/hooks/useAppState.js index 7806677..74d30b7 100644 --- a/assets/js/hooks/useAppState.js +++ b/assets/js/hooks/useAppState.js @@ -29,7 +29,7 @@ import { activityVariantKeys, placeVariantKeys, workVariantKeys, UTCI_ENVIRONMENTS, VARIANT_DEFAULT_ENV, } from '../config.js'; -import { utciCategory } from '../utils.js'; +import { utciCategory, VEHICLE_SPEEDS } from '../utils.js'; import { buildHourlyRows, aggregateRows } from '../compute.js'; import { useForecast } from './useForecast.js'; import { useColumnPopup } from './useColumnPopup.js'; @@ -202,6 +202,14 @@ export function useAppState() { try { localStorage.setItem('sunscope_vehicle_vent', v ? '1' : '0'); } catch (e) { /* ignore */ } setVehicleVent(v); }; + // Road-speed class (key into VEHICLE_SPEEDS): 'static' | 'urban' | 'aroad' | 'motorway'. + const [vehicleSpeed, setVehicleSpeed] = useState(() => { + try { return localStorage.getItem('sunscope_vehicle_speed') || 'static'; } catch (e) { return 'static'; } + }); + const setVehicleSpeedAndSave = (v) => { + try { localStorage.setItem('sunscope_vehicle_speed', v); } catch (e) { /* ignore */ } + setVehicleSpeed(v); + }; const [outdoorsVariant, setOutdoorsVariant] = useState(() => { try { return localStorage.getItem('sunscope_outdoors_variant') || 'urban'; } catch (e) { return 'urban'; } @@ -220,6 +228,12 @@ export function useAppState() { // Auto-set UTCI environment modifier to match the selected variant. const defaultEnv = VARIANT_DEFAULT_ENV[v] ?? 'open'; setUtciEnvAndSave(defaultEnv); + // The Driver profile is about cab comfort - default the vehicle model to a + // truck cab travelling at A-road speed. + if (v === 'driver') { + setVehicleTypeAndSave('truck'); + setVehicleSpeedAndSave('aroad'); + } }; const activeCols = activeProfile === 'outdoors' @@ -458,7 +472,9 @@ export function useAppState() { // ── 9. COMPUTATION ─────────────────────────────────────────────────── const { hourlyRows, days, utcOffsetMs } = buildHourlyRows({ - forecast, airQuality, location, vehicleType, vehicleVent, buildingType, utciEnv, + forecast, airQuality, location, vehicleType, vehicleVent, + vehicleSpeed: (VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).mph, + buildingType, utciEnv, }); const visible = days[selectedDay]?.rows || []; @@ -600,6 +616,7 @@ export function useAppState() { skinType, setSkinType, vehicleType, setVehicleType: setVehicleTypeAndSave, vehicleVent, setVehicleVent: setVehicleVentAndSave, + vehicleSpeed, setVehicleSpeed: setVehicleSpeedAndSave, outdoorsVariant, setOutdoorsVariantAndSave, buildingType, setBuildingType: setBuildingTypeAndSave, indoorManaged, setIndoorManaged: setIndoorManagedAndSave, diff --git a/assets/js/physics.js b/assets/js/physics.js index 363efc6..9cb4fad 100644 --- a/assets/js/physics.js +++ b/assets/js/physics.js @@ -11,7 +11,7 @@ // 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) +// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated, speedMph) // vaporPressureHpa(Ta, RH) Magnus formula - hPa // solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position (-) // calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temperature @@ -396,17 +396,24 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType // 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) { +export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car', ventilated = false, speedMph = 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. + 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 - // hOut ~10 W/m-K (light breeze over panel surface even when parked) - const hOut = 10; + // 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); 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. @@ -437,10 +444,14 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'c const totalGain = conductionGain + glazingGain; // Cabin heat rejection: an effective blend of leakage, internal air volume, // and surfaces exchanging heat with the outside. With windows open it 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; + // roughly 5x higher when parked - air moves freely through the cabin, + // flushing heat out and capping interior temperature much closer to ambient. + // 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 effectiveHLoss = preset.hCabinLoss * lossMult; const thermalMass = preset.thermalMass ?? 1; const solarRise = (totalGain / effectiveHLoss) * thermalMass; @@ -493,30 +504,13 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'c // // ----------------------------------------------------------------------------- -// -- FUTURE FEATURE: Vehicle-at-speed thermal model ---------------------------- -// Idea: extend calcVehicleInteriorTemp (or add a companion function) to model -// cabin temperature for a vehicle travelling at speed, not just parked. +// -- 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 reproduces the static model. // -// Key physics differences from the static model: -// - Forced convection over the shell scales with vehicle speed (v-), so -// hOut rises significantly - shell cools much faster than when parked. -// - Above ~30 mph the vehicle's own forward motion dominates airflow, so -// ambient wind direction becomes largely irrelevant (simplifies the model). -// - Speed classes to model: urban (~20 mph), dual carriageway (~50 mph), -// motorway (~70 mph) - each with a derived hOut multiplier. -// - Windows-open behaviour changes completely at speed: at 70 mph open -// windows create high-velocity through-flow, dramatically cutting cabin -// temp vs. the sealed-car case (but much less pleasant than AC!). -// - Roof and bonnet solar gain stays the same; side-glass gain is unchanged. -// - AC-off vs AC-on would be the primary user toggle alongside speed class. -// -// Suggested signature: -// calcVehicleInteriorTempAtSpeed(Ta, globalRad, solElev, vehicleType, speedMph, windowsOpen) -// -// This would be a useful companion output column (e.g. "Vehicle (moving)") -// for road-trip planning, dog-in-car safety at a rest stop vs. motorway, etc. -// -// -- CYCLIST AT SPEED (companion to the above) -------------------------------- +// -- 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: diff --git a/assets/js/utils.js b/assets/js/utils.js index 808b600..37fcf7e 100644 --- a/assets/js/utils.js +++ b/assets/js/utils.js @@ -176,10 +176,26 @@ 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 }, }; +// ------------------------------------------------------------------- +// VEHICLE SPEEDS - road-speed classes for the cabin heat model. +// ------------------------------------------------------------------- +// mph feeds calcVehicleInteriorTemp's speedMph argument. 'static' (0 mph) +// is a parked vehicle and reproduces the original stationary model exactly; +// higher speeds scrub the shell with forced airflow and (windows down) flush +// the cabin toward ambient. +// ------------------------------------------------------------------- +export const VEHICLE_SPEEDS = { + static: { name: 'Static', mph: 0 }, + urban: { name: 'Urban 20mph', mph: 20 }, + aroad: { name: 'A-road 50mph', mph: 50 }, + motorway:{ name: 'Motorway 70mph', mph: 70 }, +}; + // ------------------------------------------------------------------- // BUILDING TYPES - presets for the indoor temperature model. // ------------------------------------------------------------------- diff --git a/data/tracking.json b/data/tracking.json index a6fcb4b..15df994 100644 --- a/data/tracking.json +++ b/data/tracking.json @@ -108,5 +108,12 @@ "basic": 16, "farming": 2 } + }, + "2026-06-27": { + "visits": 7, + "profiles": { + "farming": 3, + "basic": 1 + } } } \ No newline at end of file