Files
sunscope/assets/js/compute.js
T
fraxle 0b260affd7 3.2.2
Updated logo
Added average seasonal temps compare
2026-07-07 13:23:08 +01:00

857 lines
38 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, 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:
// - 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, calcShadeAirTemp,
} from './physics.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel } from './utils.js';
import { UTCI_ENVIRONMENTS, CROP_CALENDAR } from './config.js';
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;
// 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 lightning = h.lightning_potential ? (h.lightning_potential[i] ?? 0) : 0;
const cape = h.cape ? (h.cape[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;
// 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;
// concreteT is now stamped in the two-pass section below (thermal lag).
// Shade air temp: the air you'd feel in this Solar Model's typical shade
// (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);
const eh = vaporPressureHpa(Ta, RH);
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 *This will need a airflow calc as going at speed is cooling
// Running: 0.60 *This will need a airflow calc as going at speed is cooling
// Sport: 0.50 *This will need a airflow calc as going at speed is cooling
//
// *Danny Notes: These faster speed activities might need intergrating into the future "Vehicle Speed" calcs somehow?
//
// 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, lightning, cape, snow,
soilT0, soilT6, soilM, vehicleT, shadeT,
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 };
}
// ------------------------------------------------------------------------
// aggregateRows(rows, interval) - Compress a day's hourly rows into
// multi-hour buckets for the table view (1h / 2h / 3h / 4h).
//
// Buckets are CLOCK-ALIGNED from midnight, so a 2h view groups 00-01,
// 02-03, ...; 3h groups 00-02, 03-05, ...; etc. Each output row keeps the
// LANDING (first) hour's positional fields - iso, dt, solar elevation,
// global radiation, wind direction, sky/cloud category - so the little
// scope, clock label and wind vane all show the time the column lands on.
//
// Every other column is aggregated with the rule that fits its meaning:
// - SUM : precipitation totals (1mm/h x 3h = 3mm)
// - MAX : risk/peak columns (rain %, UV, gusts, cabin/surface/indoor)
// - MEAN : smooth continuous quantities (air temp, RH, wind, cloud, ...)
// - FELT : worst-case felt temp - the warmest hour if it reaches >=20C
// (heat is the concern), otherwise the coldest (cold concern)
//
// Derived render values (Delta, Burn) are recomputed downstream from the
// aggregated Air / UTCI / UV, so they stay consistent automatically.
//
// interval <= 1 returns the input untouched (zero behaviour change).
// ------------------------------------------------------------------------
const AGG_MEAN = [
'Ta', 'shadeT', 'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh',
'soilT0', 'soilT6', 'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad',
'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen',
];
const AGG_MAX = [
'precipProb', 'uv', 'uvA', 'uvB', 'gust',
'vehicleT', 'concreteT', 'indoorT', 'managedT',
];
const AGG_SUM = ['precip', 'snow'];
const AGG_FELT = ['utci', 'utciAdj'];
export function aggregateRows(rows, interval) {
if (!rows || rows.length === 0 || !interval || interval <= 1) return rows;
// Numbers for a field across the group, skipping null / NaN.
const nums = (group, f) => group.map(r => r[f]).filter(v => v != null && !isNaN(v));
const mean = (vals) => vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
const maxV = (vals) => vals.length ? Math.max(...vals) : null;
const felt = (vals) => {
if (!vals.length) return null;
const hi = Math.max(...vals);
return hi >= 20 ? hi : Math.min(...vals);
};
// Split rows into clock-aligned buckets by floor(localHour / interval).
const buckets = [];
let cur = null, curKey = null;
for (const r of rows) {
const hour = parseInt(r.iso.slice(11, 13), 10);
const key = Math.floor(hour / interval);
if (key !== curKey) { cur = []; buckets.push(cur); curKey = key; }
cur.push(r);
}
return buckets.map(group => {
// Start from the landing row so positional fields (iso, dt, elev, glob,
// wd, compass, cloudCat) carry through unchanged, then overwrite the
// aggregatable columns.
const out = { ...group[0] };
AGG_MEAN.forEach(f => { out[f] = mean(nums(group, f)); });
AGG_MAX.forEach(f => { out[f] = maxV(nums(group, f)); });
AGG_SUM.forEach(f => { out[f] = group.reduce((a, r) => a + (r[f] || 0), 0); });
AGG_FELT.forEach(f => { out[f] = felt(nums(group, f)); });
// Bucket span markers used by the table for "now" highlighting and for
// matching event cell-tags that fall on any hour within the bucket.
out.isoHours = group.map(r => r.iso.slice(0, 13));
out.isoEnd = group[group.length - 1].iso;
return out;
});
}
// ------------------------------------------------------------------------
// 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;
// Environment-adjusted air temp (same as used in buildHourlyRows).
const TaEnv = Ta + (env ? env.taOffset : 0);
// Sequential UTCI isolation — all deltas are in the same UTCI-polynomial
// space so they add up: Ta + environment + sunAndSky + wind + humidity
// + precipitation ≈ SunSoak (utciAdj), within rounding.
//
// Tmrt already has env radiation scaling (dirFactor/difFactor/globFactor)
// baked in from buildHourlyRows, so each delta automatically reflects
// the active Solar Model without any extra work here.
const ehNeutral = vaporPressureHpa(TaEnv, 50);
const base = utciApprox(TaEnv, TaEnv, 0, ehNeutral); // ≈ TaEnv
// Radiation: effect of actual Tmrt vs no-radiation baseline (wind=0, RH=50%).
const sunAndSky = r1(utciApprox(TaEnv, Tmrt, 0, ehNeutral) - base);
// Wind: cooling effect of actual wind with solar present (RH still neutral).
const wind = r1(utciApprox(TaEnv, Tmrt, va, ehNeutral) - utciApprox(TaEnv, Tmrt, 0, ehNeutral));
// Humidity: actual vapour pressure vs neutral RH 50%, with solar and wind.
const humidity = r1(utciApprox(TaEnv, Tmrt, va, eh) - utciApprox(TaEnv, Tmrt, va, 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 { base: r1(base), 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, cols = null, vehicleSpeed = 'static', weekDays = null, lat = null, normals = null, dayKey = null) {
if (!todayRows || todayRows.length === 0) return [];
const show = (key) => !cols || !!cols[key];
const hhmm = (iso) => {
if (!iso) return null;
const h = parseInt(iso.slice(11, 13), 10);
const m = iso.slice(14, 16);
const period = h < 12 ? 'am' : 'pm';
const h12 = h % 12 || 12;
return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
};
const hhmmEnd = (iso) => {
if (!iso) return null;
const h = parseInt(iso.slice(11, 13), 10) + 1;
const m = iso.slice(14, 16);
const period = (h % 24) < 12 ? 'am' : 'pm';
const h12 = h % 12 || 12;
return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
};
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 peakRainRow = todayRows.reduce((best, r) =>
(r.precipProb != null && (best == null || r.precipProb > best.precipProb)) ? r : best, null);
const maxRainProb = peakRainRow ? (peakRainRow.precipProb ?? 0) : 0;
// 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 };
} else if (cur) {
out.push(cur); cur = null;
}
}
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) => {
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 lightningLabel = (v) => {
if (v <= 0) return null;
if (v < 5) return 'Low';
if (v < 25) return 'Moderate';
return 'High';
};
const peakLightningRow = todayRows.reduce((best, r) =>
(r.lightning ?? 0) > (best?.lightning ?? 0) ? r : best, null);
const peakLightning = peakLightningRow?.lightning ?? 0;
const lightningRisk = lightningLabel(peakLightning);
const lightningItem = lightningRisk ? [{
icon: '⚡',
label: 'Lightning risk',
value: `${lightningRisk} at ${hhmm(peakLightningRow.iso)}`,
alert: peakLightning >= 5,
}] : [];
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: maxRainProb > 0 && peakRainRow?.iso
? `${Math.round(maxRainProb)}% at ${hhmm(peakRainRow.iso)}`
: `${Math.round(maxRainProb)}%`,
alert: maxRainProb >= 60,
};
// ── Vs seasonal average (climate anomaly) ──────────────────────────────
// Shown across every profile: how far this day's mean air temp sits above or
// below the 1991-2020 normal for the date. A small, honest climate-change cue.
const climateItem = (() => {
if (!normals) return [];
const key = dayKey || todayRows[0]?.iso?.slice(0, 10);
if (!key) return [];
const LEAP_CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
const doy = LEAP_CUM[parseInt(key.slice(5, 7), 10) - 1] + (parseInt(key.slice(8, 10), 10) - 1);
const normal = normals[doy];
if (normal == null) return [];
const taVals = todayRows.map(r => r.Ta).filter(v => v != null);
if (taVals.length === 0) return [];
const dayMean = taVals.reduce((a, b) => a + b, 0) / taVals.length;
const anomaly = dayMean - normal;
const mag = Math.abs(anomaly);
const value = mag < 0.5
? 'About average'
: `${anomaly >= 0 ? '+' : ''}${mag.toFixed(1)}° ${anomaly >= 0 ? 'warmer' : 'cooler'}`;
return [{
icon: '🌡',
label: '1991-2020 Average',
value,
alert: anomaly >= 5,
}];
})();
// ── 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 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);
const soilM = todayRows.find(r => r.soilM != null)?.soilM ?? null;
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
return [
...climateItem,
{
icon: '⏱',
label: 'Best field work',
value: formatWindows(fieldWins) ?? 'No suitable window',
alert: fieldWins.length === 0,
},
...(show('soilT') ? [{
icon: '🌱',
label: 'Soil warms to 10°C by',
value: soilWarmRow ? (hhmm(soilWarmRow.iso) === '00:00' ? 'All Day' : hhmm(soilWarmRow.iso)) : 'Not today',
alert: !soilWarmRow,
}] : []),
...(show('precipProb') ? [rainItem] : []),
...lightningItem,
...(show('soilM') ? [{
icon: '💧',
label: 'Soil moisture',
value: moistureLabel(soilM) ?? '-',
alert: soilM != null && (soilM < 0.10 || soilM > 0.50),
}] : []),
...(weekDays ? computeCropAdvice(weekDays, lat) : []),
...(show('pollen') && 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 >= 29);
return [
...climateItem,
...(show('vehicleT') ? [{
icon: '🌡',
label: 'Peak cabin temp',
value: peakCabin ? `${Math.round(peakCabin.vehicleT)}° at ${hhmm(peakCabin.iso)}` : '-',
alert: !!(peakCabin && peakCabin.vehicleT > 31.5),
}, {
icon: '🧒',
label: 'Children/pets in car',
value: dangerFrom ? `Unsafe from ${hhmm(dangerFrom.iso)}` : 'Safe all day',
alert: !!dangerFrom,
}] : []),
...(show('precipProb') ? [rainItem] : []),
...lightningItem,
...(drivingShown ? [drivingItem] : []),
{
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 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));
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
return [
...climateItem,
...(show('indoorT') ? [{
icon: '🌡',
label: 'Peak indoor (unmanaged)',
value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-',
alert: !!(peakIndoor && peakIndoor.indoorT >= 28),
}] : []),
...(show('managedT') ? [{
icon: '🌡',
label: 'Peak indoor (managed)',
value: peakManaged ? `${Math.round(peakManaged.managedT)}°` : '-',
alert: !!(peakManaged && peakManaged.managedT >= 28),
}] : []),
...(show('indoorT') ? [{
icon: '🪟',
label: 'Open windows',
value: formatWindows(ventWins) ?? 'Keep closed',
alert: false,
}] : []),
...lightningItem,
...(show('aqi') ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
alert: peakAqi >= 60,
}] : []),
...(show('pollen') && maxPollen >= 10 ? [{
icon: '🌼',
label: 'Pollen',
value: pollenLabel(maxPollen),
alert: maxPollen >= 50,
}] : []),
];
}
// ── Activities - running / cycling ─────────────────────────────────────
if (variant === 'running' || variant === 'cycling') {
const coolWins = allWindows(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 [
...climateItem,
{
icon: '⏱',
label: `Best ${variant} window`,
value: formatWindows(coolWins) ?? 'No cool window today',
alert: coolWins.length === 0,
},
...(show('precipProb') ? [rainItem] : []),
...lightningItem,
...(show('burn') && burnMins ? [{
icon: '☀',
label: 'UV burn time (peak)',
value: burnMins,
alert: !!(peakUvRow && peakUvRow.uv >= 6),
}] : []),
...(show('aqi') ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
alert: peakAqi >= 60,
}] : []),
...(show('pollen') && maxPollen >= 10 ? [{
icon: '🌿',
label: 'Pollen',
value: pollenLabel(maxPollen),
alert: maxPollen >= 50,
}] : []),
];
}
// ── Outdoors - beach, park, events etc. (and fallback) ─────────────────
const comfortWins = allWindows(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 maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
const burnMins = peakUvRow && peakUvRow.uv > 0
? burnLabel(sunburnMinutes(peakUvRow.uv, skinType))
: null;
return [
...climateItem,
...(drivingShown ? [drivingItem] : []),
{
icon: '🌤',
label: 'Comfortable window',
value: formatWindows(comfortWins) ?? 'No comfortable window',
alert: comfortWins.length === 0,
},
{
icon: '🌡',
label: 'Peak felt temp',
value: peakFelt ? `${Math.round(peakFelt.utciAdj)}° at ${hhmm(peakFelt.iso)}` : '-',
alert: !!(peakFelt && (peakFelt.utciAdj >= 32 || peakFelt.utciAdj < 0)),
},
...((show('burn') || show('uvA')) && burnMins ? [{
icon: '☀',
label: 'UV burn time (peak)',
value: burnMins,
alert: !!(peakUvRow && peakUvRow.uv >= 6),
}] : []),
...(show('precipProb') ? [rainItem] : []),
...lightningItem,
...(show('aqi') ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
alert: peakAqi >= 60,
}] : []),
...(show('pollen') && maxPollen >= 10 ? [{
icon: '🌿',
label: 'Pollen',
value: pollenLabel(maxPollen),
alert: maxPollen >= 50,
}] : []),
];
}
// ------------------------------------------------------------------------
// computeCropAdvice(weekDays, lat) - Seasonal "Good to sow / harvest"
// advice for the Farming "At a glance" panel.
//
// Blends the current calendar month (which crops are in their sow/harvest
// window) with the upcoming week's weather (is the seedbed warm and
// workable, is there a dry spell to bring grain in?). Returns 0-2
// glance-style { icon, label, value, alert } items, appended after the
// standard farming insights.
//
// Parameters:
// weekDays - the `days` array: [{ key: 'YYYY-MM-DD', rows: [...] }, ...]
// lat - forecast latitude; < 0 flips the UK calendar by +6 months
// ------------------------------------------------------------------------
export function computeCropAdvice(weekDays, lat) {
if (!weekDays || weekDays.length === 0) return [];
const days = weekDays.slice(0, 7).filter(d => d && d.rows && d.rows.length);
if (days.length === 0) return [];
// "Now" - the month of the first available day (1-12).
const month = parseInt((days[0].key || '').slice(5, 7), 10);
if (!month) return [];
// Southern hemisphere: shift the stored UK months by +6 before testing.
const south = lat != null && lat < 0;
const inSeason = (months) => {
const shifted = south ? months.map(m => ((m + 5) % 12) + 1) : months;
return shifted.includes(month);
};
// Warmth the seedbed actually reaches this week: the highest of each day's
// peak surface soil temperature.
let weekSoilT = null;
for (const d of days) {
const peak = Math.max(-Infinity, ...d.rows.map(r => r.soilT0 ?? -Infinity));
if (peak > -Infinity && (weekSoilT == null || peak > weekSoilT)) weekSoilT = peak;
}
// Latest known soil moisture - saturated ground (>=45%) is unworkable.
let soilM = null;
for (const d of days) {
for (const r of d.rows) if (r.soilM != null) soilM = r.soilM;
}
const groundWet = soilM != null && soilM >= 0.45;
// Dry days this week: under 2 mm total rain and rain chance staying < 40%.
const dryNames = [];
for (const d of days) {
const totalRain = d.rows.reduce((s, r) => s + (r.precip ?? 0), 0);
const maxProb = Math.max(0, ...d.rows.map(r => r.precipProb ?? 0));
if (totalRain < 2 && maxProb < 40) {
const dt = new Date((d.key || '') + 'T00:00Z');
dryNames.push(dt.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }));
}
}
const hasDrySpell = dryNames.length > 0;
// Full crop-name list for the glance row.
const listCrops = (crops) => crops.map(c => c.label).join(', ');
const dryRange = () => dryNames.length === 1
? `dry ${dryNames[0]}`
: `dry ${dryNames[0]}${dryNames[dryNames.length - 1]}`;
const out = [];
// ── Good to sow ──────────────────────────────────────────────────────────
const sowSeason = CROP_CALENDAR.filter(c => inSeason(c.sow.months));
if (sowSeason.length) {
const ready = sowSeason.filter(c =>
weekSoilT != null && weekSoilT >= c.sow.minSoilT && !groundWet);
out.push(ready.length ? {
icon: '🌱', label: 'Good to sow', value: listCrops(ready), alert: false,
} : {
icon: '🌱', label: 'Good to sow',
value: groundWet ? 'Hold off — ground too wet' : 'Hold off — soil still cold',
alert: true,
});
}
// ── Good to harvest ──────────────────────────────────────────────────────
const harvestSeason = CROP_CALENDAR.filter(c => inSeason(c.harvest.months));
if (harvestSeason.length) {
// Grain/rape/onions need a dry spell; everything else can be lifted in window.
const ready = harvestSeason.filter(c => !c.harvest.dry || hasDrySpell);
const dryDriven = hasDrySpell && ready.some(c => c.harvest.dry);
out.push(ready.length ? {
icon: '🌾', label: 'Good to harvest',
value: dryDriven ? `${listCrops(ready)} (${dryRange()})` : listCrops(ready),
alert: false,
} : {
icon: '🌾', label: 'Good to harvest',
value: 'Hold — too wet to harvest grain', alert: true,
});
}
return out;
}