225 lines
12 KiB
JavaScript
225 lines
12 KiB
JavaScript
// ------------------------------------------------------------------------
|
|
// 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 } from './utils.js';
|
|
|
|
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType }) {
|
|
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 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);
|
|
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
|
|
const utci = utciApprox(Ta, 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, 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 };
|
|
}
|