Improved 'Sow' and 'Harvest' calcs
Better background on the event banners
This commit is contained in:
fraxle
2026-08-02 16:22:58 +01:00
parent e55e0df86f
commit 566d8d36e2
8 changed files with 291 additions and 71 deletions
+175 -47
View File
@@ -306,12 +306,15 @@ const AGG_FELT = [
'soilT0', 'soilT6', 'shadeT', 'Ta', // Soil surface + root, Shade, Air
];
// Arithmetic mean of an array, or null when empty. Shared by aggregateRows
// and computeCropAdvice.
const mean = (vals) => vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
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;
@@ -883,10 +886,59 @@ alert: peakAqi >= 60,
// glance-style { icon, label, value, alert } items, appended after the
// standard farming insights.
//
// Each verdict is one of three tiers - clearly good (crops listed plain),
// borderline (crops listed with a "(marginal)" suffix) or hold. The middle
// tier exists so a technically-passing but shaky call doesn't read with the
// same confidence as an ideal one.
//
// Sowing looks at the DAILY MEAN soil temperature at 6 cm - drilling depth -
// rather than a surface peak, because bare soil at 0 cm swings 15 °C+ over a
// day and a sunny afternoon says nothing about the seedbed. It wants that
// threshold held for a run of consecutive days AND the soil trending warmer
// ("at temperature and rising"), plus workable moisture, no air frost ahead
// for tender crops, and no downpour due straight after drilling.
//
// Harvest wants a genuinely CONSECUTIVE dry run for grain/rape/onions, and
// gates root crops on soil moisture - lifting spuds off saturated ground
// means ruts, compaction and damaged tubers.
//
// Accuracy caveats worth knowing before trusting a verdict:
// - Soil fields come from ICON Global at ~11 km (see buildSoilUrl in
// hooks/useForecast.js) - a regional average, not this field.
// - The 0.45 m³/m³ wetness threshold is texture-agnostic: near saturation
// on sand, around field capacity on clay. A proper fix needs a soil
// texture lookup.
// - soil_moisture_0_to_1cm is the skin layer and dries within hours of
// rain, so it overstates workability after a shower.
// soil_moisture_3_to_9cm would be the better input if this is revisited.
//
// Parameters:
// weekDays - the `days` array: [{ key: 'YYYY-MM-DD', rows: [...] }, ...]
// lat - forecast latitude; < 0 flips the UK calendar by +6 months
// ------------------------------------------------------------------------
// Soil moisture bands shared by the sow and harvest gates (m³/m³). WET matches
// the "Saturated" cut in moistureLabel() so the two glance rows agree.
const SOIL_WET = 0.45;
const SOIL_BORDERLINE = 0.40;
// LONGEST run of consecutive entries satisfying `pred`, as { start, len }.
// Deliberately the longest and not the first: one unsettled day early in the
// week must not hide a good four-day spell behind it.
function bestRun(arr, pred) {
let best = { start: -1, len: 0 }, start = -1, len = 0;
for (let i = 0; i < arr.length; i++) {
if (pred(arr[i])) {
if (start < 0) start = i;
len++;
if (len > best.len) best = { start, len };
} else {
start = -1; len = 0;
}
}
return best;
}
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);
@@ -903,69 +955,145 @@ export function computeCropAdvice(weekDays, lat) {
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;
}
// ── Per-day aggregates ───────────────────────────────────────────────────
// One pass; everything below reads from these rather than re-scanning rows.
const daily = days.map(d => {
const at6 = d.rows.map(r => r.soilT6).filter(v => v != null);
const at0 = d.rows.map(r => r.soilT0).filter(v => v != null);
const dt = new Date((d.key || '') + 'T00:00Z');
return {
// 6 cm is drilling depth; fall back to the 0 cm skin only if a model
// swap drops the 6 cm field, so the row still says something.
soilMeanT: at6.length ? mean(at6) : (at0.length ? mean(at0) : null),
soilMMean: mean(d.rows.map(r => r.soilM).filter(v => v != null)),
rainTotal: d.rows.reduce((s, r) => s + (r.precip ?? 0), 0),
maxProb: Math.max(0, ...d.rows.map(r => r.precipProb ?? 0)),
minTa: Math.min(Infinity, ...d.rows.map(r => r.Ta ?? Infinity)),
name: dt.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }),
};
});
// 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;
// ── Seedbed signals ──────────────────────────────────────────────────────
// Longest consecutive run of days whose mean seedbed temp clears `minT`.
const runAt = (minT) => bestRun(daily, d => d.soilMeanT != null && d.soilMeanT >= minT).len;
// 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;
// "At temperature AND rising": end of the week warmer than the start.
const early = mean(daily.slice(0, 3).map(d => d.soilMeanT).filter(v => v != null));
const late = mean(daily.slice(4, 7).map(d => d.soilMeanT).filter(v => v != null));
const rising = early != null && late != null && (late - early) >= 0.3;
// Full crop-name list for the glance row.
// Best margin over a threshold across the week - a soil sitting well above
// the floor is a safer call than one scraping it.
const bestMean = Math.max(-Infinity, ...daily.map(d => d.soilMeanT ?? -Infinity));
const soilKnown = bestMean > -Infinity;
const marginOver = (minT) => (soilKnown ? bestMean - minT : null);
// Workability now, not seven days out: mean over the next 48 h only.
const nearMoist = mean(daily.slice(0, 2).map(d => d.soilMMean).filter(v => v != null));
const groundWet = nearMoist != null && nearMoist >= SOIL_WET;
const groundDamp = nearMoist != null && nearMoist >= SOIL_BORDERLINE && nearMoist < SOIL_WET;
// Air frost anywhere in the window rules out tender crops.
const frostAhead = daily.some(d => d.minTa < 0);
// A downpour onto a fresh seedbed caps it; downgrade, never block.
const postSowSoak = daily.slice(0, 3).reduce((s, d) => s + d.rainTotal, 0) > 25;
// ── Shared row plumbing ──────────────────────────────────────────────────
const listCrops = (crops) => crops.map(c => c.label).join(', ');
const dryRange = () => dryNames.length === 1
? `dry ${dryNames[0]}`
: `dry ${dryNames[0]}${dryNames[dryNames.length - 1]}`;
// Split a season list into ready / marginal by a per-crop classifier.
const classify = (crops, fn) => {
const ready = [], marginal = [];
for (const c of crops) {
const v = fn(c);
if (v === 'ready') ready.push(c);
else if (v === 'marginal') marginal.push(c);
}
return { ready, marginal };
};
// Ready crops list plain; otherwise marginal crops carry the suffix. Keeping
// them separate means a plain list always means "genuinely good to go".
const buildRow = (icon, label, { ready, marginal }, readyValue, holdValue) => (
ready.length ? {
icon, label, value: readyValue(ready), alert: false, grp: 'surface',
} : marginal.length ? {
icon, label, value: `${listCrops(marginal)} (marginal)`, alert: false, grp: 'surface',
} : {
icon, label, value: holdValue, alert: true, grp: 'surface',
}
);
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, grp: 'surface',
} : {
icon: '🌱', label: 'Good to sow',
value: groundWet ? 'Hold off — ground too wet' : 'Hold off — soil still cold',
alert: true, grp: 'surface',
const sowState = classify(sowSeason, (c) => {
// Under glass: the outdoor seedbed simply doesn't apply.
if (c.sow.underCover) return 'ready';
// No soil reading at all is "unknown", not "fine" - don't guess.
if (!soilKnown) return 'no';
const run = runAt(c.sow.minSoilT);
if (groundWet || run === 0) return 'no';
if (c.sow.tender && frostAhead) return 'no';
const margin = marginOver(c.sow.minSoilT);
const solid = run >= 3 && (rising || (margin != null && margin >= 2));
return (solid && !groundDamp && !postSowSoak) ? 'ready' : 'marginal';
});
// Under-cover crops pass on the calendar alone, so on their own they must
// NOT read as a green light for the field - qualify them instead.
const onlyUnderCover = sowState.ready.length > 0
&& sowState.ready.every(c => c.sow.underCover);
const sowReadyValue = (ready) => onlyUnderCover
? `${listCrops(ready)} (under cover)`
: listCrops(ready);
// Hold reason by precedence: unknown beats wet beats cold beats frost.
const holdValue = !soilKnown ? 'Soil data unavailable'
: groundWet ? 'Hold off — ground too wet'
: sowSeason.some(c => runAt(c.sow.minSoilT) === 0) ? 'Hold off — soil still cold'
: 'Hold off — frost forecast';
out.push(buildRow('🌱', 'Good to sow', sowState, sowReadyValue, holdValue));
}
// ── 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, grp: 'surface',
} : {
icon: '🌾', label: 'Good to harvest',
value: 'Hold — too wet to harvest grain', alert: true, grp: 'surface',
// A dry SPELL, not scattered dry days: under 2 mm and rain chance < 40%.
const isDry = (d) => d.rainTotal < 2 && d.maxProb < 40;
const spell = bestRun(daily, isDry);
const dryRange = () => {
const names = daily.slice(spell.start, spell.start + spell.len).map(d => d.name);
return names.length === 1
? `dry ${names[0]}`
: `dry ${names[0]}${names[names.length - 1]}`;
};
const harvestState = classify(harvestSeason, (c) => {
// Grain, rape and onions must come in / cure dry.
if (c.harvest.dry) return spell.len >= 3 ? 'ready' : spell.len === 2 ? 'marginal' : 'no';
// Root crops are lifted by machine - saturated ground means ruts.
if (c.harvest.lift) return groundWet ? 'no' : groundDamp ? 'marginal' : 'ready';
// Hand-cut (lettuce): month window is enough.
return 'ready';
});
const dryDriven = spell.len > 0 && harvestState.ready.some(c => c.harvest.dry);
const readyValue = (ready) => dryDriven
? `${listCrops(ready)} (${dryRange()})`
: listCrops(ready);
// Hold reason: only blame the ground when every stuck crop is a root crop.
// (A hold means nothing was ready or marginal, so the whole season is stuck.)
const holdValue = harvestSeason.every(c => c.harvest.lift)
? 'Hold — ground too wet to lift'
: 'Hold — too wet to harvest grain';
out.push(buildRow('🌾', 'Good to harvest', harvestState, readyValue, holdValue));
}
return out;