Files
sunscope/assets/js/compute.js
T
fraxle 099f2924d3 2.2
Add new quick summary panels and update layout to make space for them
2026-06-02 00:17:18 +01:00

547 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ------------------------------------------------------------------------
// 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 }.
//
// 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:
// - String slices (iso.slice(...)) for display & day grouping
// - A true UTC Date (dt) for solarElevationDeg (which uses .getUTC*).
// ------------------------------------------------------------------------
import {
vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox,
calcConcreteTempPass, calcVehicleInteriorTemp,
calcIndoorTempPass, calcManagedIndoorTempPass,
} from './physics.js';
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 }) {
const env = UTCI_ENVIRONMENTS[utciEnv] ?? UTCI_ENVIRONMENTS.open;
const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000;
// Build a fast lookup map from the air quality hourly data: ISO string - index.
// Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like
// "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14".
const aqTimeMap = {};
if (airQuality?.hourly?.time) {
airQuality.hourly.time.forEach((t, i) => { aqTimeMap[t.slice(0, 13)] = i; });
}
const getAq = (field, iso) => {
if (!airQuality?.hourly?.[field]) return null;
const i = aqTimeMap[iso.slice(0, 13)];
if (i === undefined) return null;
return airQuality.hourly[field][i] ?? null;
};
const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => {
const h = forecast.hourly;
const Ta = h.temperature_2m[i];
const RH = h.relative_humidity_2m[i];
const dew = h.dew_point_2m ? h.dew_point_2m[i] : null;
const va = h.wind_speed_10m[i];
const wd = h.wind_direction_10m ? h.wind_direction_10m[i] : null;
const gust = h.wind_gusts_10m ? h.wind_gusts_10m[i] : null;
const dir = h.direct_radiation[i] || 0;
const dif = h.diffuse_radiation[i] || 0;
const glob = h.shortwave_radiation[i] || 0;
const cc = h.cloud_cover[i];
const ccLow = h.cloud_cover_low ? h.cloud_cover_low[i] : null;
const ccMid = h.cloud_cover_mid ? h.cloud_cover_mid[i] : null;
const ccHigh = h.cloud_cover_high ? h.cloud_cover_high[i] : null;
const uv = h.uv_index ? (h.uv_index[i] || 0) : 0;
const precip = h.precipitation[i] || 0;
const precipProb = h.precipitation_probability ? (h.precipitation_probability[i] ?? 0) : 0;
const snow = h.snowfall[i] || 0;
const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null;
const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null;
const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null;
// iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z).
// For display we slice the string directly - no Date object needed.
// For solarElevationDeg (which uses .getUTC* internally) we need the
// true UTC instant: treat the local time as UTC then subtract the offset.
// e.g. Brisbane UTC+10: local 14:00 - parse as UTC 14:00 - subtract 10h - UTC 04:00 -
// iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z).
// For solarElevationDeg (which uses .getUTC* internally) we need the
// true UTC instant: treat the local time as UTC then subtract the offset.
const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
const elev = solarElevationDeg(location.lat, location.lon, dt);
// Use direct_radiation (beam sunlight) + a fraction of diffuse for concrete.
// direct_radiation is zero on fully overcast days - far more accurate than
// shortwave_radiation which can be unreliably high even at 100% cloud cover.
// Diffuse (scattered light through cloud) contributes ~20% as much heat to
// 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 eh = vaporPressureHpa(Ta, RH);
// Apply environment modifier - adjust solar inputs and air temp for shaded environments.
const TaEnv = Ta + env.taOffset;
const dirEnv = dir * env.dirFactor;
const difEnv = dif * env.difFactor;
const globEnv = glob * env.globFactor;
const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev);
const utci = utciApprox(TaEnv, Tmrt, va, eh);
const utciAdj = utci + precipPenalty(precip, snow, va);
// Derived
const compass = windCompass8(wd);
const { uvA, uvB } = uvSplit(uv, elev);
const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh);
// Visibility from the main forecast API (metres - km).
const visKm = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })();
const aqi = getAq('european_aqi', iso);
const grassPollen = getAq('grass_pollen', iso);
const birchPollen = getAq('birch_pollen', iso);
const alderPollen = getAq('alder_pollen', iso);
const mugwortPollen= getAq('mugwort_pollen', iso);
const olivePollen = getAq('olive_pollen', iso);
const ragweedPollen= getAq('ragweed_pollen', iso);
// -- FUTURE FEATURE: Activity "What If" Modifier ---------------------------
// Add two extra columns driven by a user-selected activity level. These are
// intentionally kept SEPARATE from the core columns above so that baseline
// profile data stays consistent and comparable across profiles.
//
// The user picks an activity from a simple UI picker (no live data needed -
// this is a forecast/planning tool, not a tracker):
// Resting - Walking - Cycling - Running - Sport/Intense
//
// Two output columns only (keep it clean):
//
// adjustedSafeTime - baseline UV safe exposure time - an activity multiplier.
// Higher activity = shorter safe time, because:
// - metabolic heat raises core body temp
// - sweating washes away sunscreen faster
// - more skin blood flow = higher UV sensitivity
// Suggested multipliers (tune with real data):
// Resting: 1.0 (no change)
// Walking: 0.85
// Cycling: 0.75
// Running: 0.60
// Sport: 0.50
//
// heatStressLevel - a simple label: 'Low' | 'Moderate' | 'High' | 'Very High'
// Derived from UTCI + activity heat load. A runner at
// UTCI 28-C should read 'High' even if a resting person
// would read 'Moderate' at the same UTCI.
// Colour code in the UI: - - - -
//
// Implementation sketch:
// 1. Accept 'activityLevel' as a new param to buildHourlyRows() alongside
// vehicleType, buildingType etc.
// 2. Define ACTIVITY_PRESETS in utils.js (multiplier + utciOffset per level).
// 3. Compute adjustedSafeTime = baseSafeTime * preset.multiplier
// 4. Compute heatStressLevel from (utci + preset.utciOffset) banded into labels.
// 5. Add both fields to the returned row object below.
// 6. In components.js, render these as optional columns that only appear when
// an activity other than 'Resting' is selected - keeps the default table clean.
// -----------------------------------------------------------------------------
return {
iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob,
cc, ccLow, ccMid, ccHigh, cloudCat,
uv, uvA, uvB,
precip, precipProb, snow,
soilT0, soilT6, soilM, vehicleT,
effectiveRad,
elev, Tmrt, utci, utciAdj, eh, compass,
visKm, aqi,
grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen,
};
}) : [];
// Two-pass calculations: concrete thermal lag + indoor temperature.
// Both 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);
const globArr = hourlyRows.map(r => r.glob);
const elevArr = hourlyRows.map(r => r.elev);
const radArr = hourlyRows.map(r => r.effectiveRad);
const vaArr = hourlyRows.map(r => r.va);
const uvArr = hourlyRows.map(r => r.uv);
const cloudCatArr = hourlyRows.map(r => r.cloudCat);
const soilMArr = hourlyRows.map(r => r.soilM);
const precipArr = hourlyRows.map(r => r.precip);
const snowArr = hourlyRows.map(r => r.snow);
// Concrete surface temperature with thermal lag (1.5 h time constant).
// A slab baking in the sun retains heat when cloud rolls in, and takes
// a couple of hours of sunshine to fully heat up from a cold start.
const concreteTemps = calcConcreteTempPass(
TaArr, radArr, vaArr, elevArr, uvArr, cloudCatArr, soilMArr, precipArr, snowArr
);
hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; });
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]; });
}
// Group those hourly rows into days for the day tabs.
const days = [];
hourlyRows.forEach(row => {
const key = row.iso.slice(0, 10);
let day = days.find(d => d.key === key);
if (!day) { day = { key, rows: [] }; days.push(day); }
day.rows.push(row);
});
// -- FUTURE FEATURE v2: Today Summary + Alert System --------------------------
// After grouping rows into days, generate a per-day summary object that powers
// a stylish "Today at a Glance" panel shown above or below the main dial.
//
// The summary is NOT a live alert/push system - it's a forecast digest that
// refreshes with the forecast data. Think of it as a smart briefing card.
//
// WHAT TO COMPUTE (per day, from that day's rows):
// - Peak UTCI+P and time it occurs - heat stress headline
// - Min UTCI+P and time - cold stress headline
// - Peak UV index and time - UV warning
// - Max precipitation rate and time - rain/ice warning
// - Max vehicle cabin temp - "dangerous to leave pets/children in car"
// - Max pollen level + type - pollen advisory
// - Road condition risk (low air temp + precip - ice risk)
//
// ALERT CATEGORIES (each generates a styled warning card if threshold exceeded):
// -- Heat warning UTCI+P > 32-C
// - Cold warning UTCI+P < 0-C
// -- UV warning UV index > 6
// -- Heavy rain precip > 4mm/h
// - Ice/road risk Ta < 3-C + any precip (or recent precip overnight)
// - Vehicle danger vehicleT > 35-C ("don't leave pets or children in car")
// - High pollen any pollen type > 50 grains/m-
//
// DESIGN NOTES:
// - Cards should be concise - one line of bold text + a short explanation
// - Colour-coded to match the existing UTCI stress band palette
// - Collapsible - show top 2-3 alerts by default, expand for full list
// - For today only (days[0]); optionally extend to day tabs in a later pass
// - The "X-C above seasonal norm" historical context line (see app.js comment)
// could live here too, as a subtle subheading under the dial temperature
//
// Suggested return shape - add to the return value below:
// daySummaries: days.map(day => buildDaySummary(day.rows))
//
// where buildDaySummary() is a new helper in this file (or a separate
// summary.js module if it grows large).
// -----------------------------------------------------------------------------
return { hourlyRows, days, utcOffsetMs };
}
// ------------------------------------------------------------------------
// computeWhyFeelsLike(row, env) - Break down the felt-temp delta into its
// contributing factors for the "Why it feels like this" panel.
//
// Returns contributions in degrees C relative to plain air temperature.
// Positive = warmer than air temp, negative = cooler.
//
// Attribution method - sequential isolation using utciApprox:
// sunAndSky - Tmrt vs Ta. Mean radiant temp captures net heat from
// direct sun, sky scatter, and ground reflection combined.
// wind - UTCI with wind vs without (Tmrt = Ta, neutral RH 50%).
// Almost always negative - wind cools.
// humidity - UTCI at actual vapour pressure vs neutral RH 50%.
// Positive when muggy, near-zero when dry.
// environment - taOffset from the active UTCI environment modifier.
// e.g. urban +2.5, forest -2.0, desert +3.5.
// precipitation - precipPenalty() - always 0 or negative.
// ------------------------------------------------------------------------
export function computeWhyFeelsLike(row, env) {
if (!row) return null;
const { Ta, Tmrt, va, eh, precip, snow } = row;
const r1 = (v) => Math.round(v * 10) / 10;
// Radiation - mean radiant temp above or below air temp.
const sunAndSky = r1(Tmrt - Ta);
// Wind - UTCI with actual wind vs calm, holding Tmrt = Ta and RH neutral.
const ehNeutral = vaporPressureHpa(Ta, 50);
const wind = r1(utciApprox(Ta, Ta, va, ehNeutral) - utciApprox(Ta, Ta, 0, ehNeutral));
// Humidity - actual vapour pressure vs neutral RH 50%, no wind or sun.
const humidity = r1(utciApprox(Ta, Ta, 0, eh) - utciApprox(Ta, Ta, 0, ehNeutral));
// Environment modifier - explicit temperature offset from the env config.
const environment = env ? r1(env.taOffset) : 0;
// Precipitation soak penalty - negative or zero.
const precipitation = precipPenalty(precip, snow, va);
return { sunAndSky, wind, humidity, environment, precipitation };
}
// ------------------------------------------------------------------------
// computeGlanceSummary(todayRows, profile, variant, skinType) - Build the
// environment-aware "Today at a Glance" items for the current day.
//
// Returns an array of { icon, label, value, alert } objects (4-5 items).
// Content varies by profile and sub-variant so it stays relevant to
// whatever the user is actually doing.
//
// Parameters:
// todayRows - hourlyRows for the selected day
// profile - active profile key e.g. "farming", "vehicle", "home"
// 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) {
if (!todayRows || todayRows.length === 0) return [];
const hhmm = (iso) => iso ? iso.slice(11, 16) : null;
const dayRows = todayRows.filter(r => r.elev > 0);
const peakRow = (field) => todayRows.reduce((best, r) =>
(r[field] != null && (best == null || r[field] > best[field])) ? r : best, null);
const maxRainProb = Math.max(0, ...todayRows.map(r => r.precipProb ?? 0));
// Longest run of rows that satisfy a predicate.
const longestWindow = (rows, cond) => {
let best = null, 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;
}
}
return best;
};
const pollenLabel = (v) => {
if (v == null || v < 0) return null;
if (v < 10) return 'Low';
if (v < 50) return 'Moderate';
if (v < 200) return 'High';
return 'Very High';
};
const aqiLabel = (v) => {
if (v == null) return null;
if (v < 20) return 'Good';
if (v < 40) return 'Fair';
if (v < 60) return 'Moderate';
return 'Poor';
};
const moistureLabel = (v) => {
if (v == null) return null;
if (v < 0.15) return 'Dry';
if (v < 0.30) return 'Slightly dry';
if (v < 0.45) return 'Moist';
return 'Saturated';
};
const rainItem = {
icon: '🌧',
label: 'Rain risk',
value: `${Math.round(maxRainProb)}%`,
alert: maxRainProb >= 60,
};
// ── Farming ────────────────────────────────────────────────────────────
if (profile === 'farming') {
const fieldWindow = longestWindow(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);
const soilM = todayRows.find(r => r.soilM != null)?.soilM ?? null;
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
return [
{
icon: '⏱',
label: 'Best field work',
value: fieldWindow
? `${hhmm(fieldWindow.start.iso)} ${hhmm(fieldWindow.end.iso)}`
: 'No suitable window',
alert: !fieldWindow,
},
{
icon: '🌱',
label: 'Soil warms to 10°C by',
value: soilWarmRow ? hhmm(soilWarmRow.iso) : 'Not today',
alert: !soilWarmRow,
},
rainItem,
{
icon: '💧',
label: 'Soil moisture',
value: moistureLabel(soilM) ?? '-',
alert: soilM != null && (soilM < 0.10 || soilM > 0.50),
},
...(maxPollen >= 10 ? [{
icon: '🌿',
label: 'Grass pollen',
value: pollenLabel(maxPollen),
alert: maxPollen >= 50,
}] : []),
];
}
// ── Vehicle ────────────────────────────────────────────────────────────
if (profile === 'vehicle') {
const peakCabin = peakRow('vehicleT');
const dangerFrom = todayRows.find(r => r.vehicleT != null && r.vehicleT >= 35);
return [
{
icon: '🌡',
label: 'Peak cabin temp',
value: peakCabin ? `${Math.round(peakCabin.vehicleT)}° at ${hhmm(peakCabin.iso)}` : '-',
alert: !!(peakCabin && peakCabin.vehicleT >= 35),
},
{
icon: '🧒',
label: 'Children/pets in car',
value: dangerFrom ? `Unsafe from ${hhmm(dangerFrom.iso)}` : 'Safe all day',
alert: !!dangerFrom,
},
rainItem,
{
icon: '🌤',
label: 'Best travel comfort',
value: (() => {
const best = todayRows.reduce((b, r) =>
(b == null || r.utciAdj < b.utciAdj) ? r : b, null);
return best ? `${hhmm(best.iso)} (${Math.round(best.utciAdj)}° felt)` : '-';
})(),
alert: false,
},
];
}
// ── Home ───────────────────────────────────────────────────────────────
if (profile === 'home') {
const peakIndoor = peakRow('indoorT');
const peakManaged = peakRow('managedT');
const ventWindow = longestWindow(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));
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
return [
{
icon: '🌡',
label: 'Peak indoor (unmanaged)',
value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-',
alert: !!(peakIndoor && peakIndoor.indoorT >= 28),
},
{
icon: '🌡',
label: 'Peak indoor (managed)',
value: peakManaged ? `${Math.round(peakManaged.managedT)}°` : '-',
alert: !!(peakManaged && peakManaged.managedT >= 28),
},
{
icon: '🪟',
label: 'Open windows',
value: ventWindow
? `${hhmm(ventWindow.start.iso)} ${hhmm(ventWindow.end.iso)}`
: 'Keep closed',
alert: false,
},
{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
alert: peakAqi >= 60,
},
...(maxPollen >= 10 ? [{
icon: '🌼',
label: 'Pollen',
value: pollenLabel(maxPollen),
alert: maxPollen >= 50,
}] : []),
];
}
// ── Activities - running / cycling ─────────────────────────────────────
if (variant === 'running' || variant === 'cycling') {
const coolWindow = longestWindow(dayRows, r =>
r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30
);
const peakUvRow = peakRow('uv');
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
const burnMins = peakUvRow && peakUvRow.uv > 0
? burnLabel(sunburnMinutes(peakUvRow.uv, skinType))
: null;
return [
{
icon: '⏱',
label: `Best ${variant} window`,
value: coolWindow
? `${hhmm(coolWindow.start.iso)} ${hhmm(coolWindow.end.iso)}`
: 'No cool window today',
alert: !coolWindow,
},
rainItem,
...(burnMins ? [{
icon: '☀',
label: 'UV burn time (peak)',
value: burnMins,
alert: !!(peakUvRow && peakUvRow.uv >= 6),
}] : []),
{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
alert: peakAqi >= 60,
},
...(maxPollen >= 10 ? [{
icon: '🌿',
label: 'Pollen',
value: pollenLabel(maxPollen),
alert: maxPollen >= 50,
}] : []),
];
}
// ── Outdoors - beach, park, events etc. (and fallback) ─────────────────
const comfortWindow = longestWindow(dayRows, r =>
r.utciAdj >= 9 && r.utciAdj <= 26 && r.precipProb < 30
);
const peakFelt = peakRow('utciAdj');
const peakUvRow = peakRow('uv');
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
const burnMins = peakUvRow && peakUvRow.uv > 0
? burnLabel(sunburnMinutes(peakUvRow.uv, skinType))
: null;
return [
{
icon: '🌤',
label: 'Comfortable window',
value: comfortWindow
? `${hhmm(comfortWindow.start.iso)} ${hhmm(comfortWindow.end.iso)}`
: 'No comfortable window',
alert: !comfortWindow,
},
{
icon: '🌡',
label: 'Peak felt temp',
value: peakFelt ? `${Math.round(peakFelt.utciAdj)}° at ${hhmm(peakFelt.iso)}` : '-',
alert: !!(peakFelt && (peakFelt.utciAdj >= 32 || peakFelt.utciAdj < 0)),
},
...(burnMins ? [{
icon: '☀',
label: 'UV burn time (peak)',
value: burnMins,
alert: !!(peakUvRow && peakUvRow.uv >= 6),
}] : []),
rainItem,
{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
alert: peakAqi >= 60,
},
];
}