diff --git a/backend/src/lib/cost-calc.js b/backend/src/lib/cost-calc.js index 26372e4..e2a5277 100644 --- a/backend/src/lib/cost-calc.js +++ b/backend/src/lib/cost-calc.js @@ -81,19 +81,55 @@ async function convertMeterConsumption(meterId, rawConsumption) { return rawConsumption } -// Consumption for a meter over [periodStart, periodEnd] (inclusive), bracketing -// the boundaries with the nearest available readings — meters get one manual -// cumulative reading, not necessarily one exactly on the period edge. -export async function getPeriodConsumption(meterId, periodStart, periodEnd) { - const first = (await readingOnOrAfter(meterId, periodStart)) || (await readingOnOrBefore(meterId, periodStart)) - const last = await readingOnOrBefore(meterId, periodEnd) +// Estimates the meter's cumulative reading value AT an arbitrary date by +// linearly interpolating between the two real readings bracketing it — the +// same "average daily rate carried forward until the next reading" logic a +// manual spreadsheet would use, generalised so a whole reporting period's +// consumption can span several sparse readings rather than being lumped onto +// whichever reading happens to land nearest the boundary. Returns null if +// `date` falls outside the range we have readings for (before the first ever, +// or after the latest) — there's no fair way to interpolate past known data +// without assuming a rate, which is what estimates.js's trailing-average +// projection is deliberately for instead. +async function interpolatedValueAtDate(meterId, date) { + const before = await readingOnOrBefore(meterId, date) + const after = await readingOnOrAfter(meterId, date) + if (!before || !after) return null - if (!first || !last || last.reading_date <= first.reading_date) { - return { consumption: null, first, last, has_data: false } + const totalDays = daysBetween(before.reading_date, after.reading_date) + if (totalDays <= 0) return { value: Number(before.reading_value), before, after } + + const daysIn = daysBetween(before.reading_date, date) + const value = Number(before.reading_value) + + (Number(after.reading_value) - Number(before.reading_value)) * (daysIn / totalDays) + return { value, before, after } +} + +// Consumption for a meter over [periodStart, periodEnd] (inclusive). Interpolates +// the meter's value at each boundary between the real readings bracketing it, +// then takes the difference — so a period's consumption is prorated by days +// across every sparse reading gap it spans, not just lumped onto the nearest +// available readings either side. Falls back to nearest-reading bracketing +// (the old behaviour) when a boundary can't be interpolated, e.g. the period +// starts before the meter's first-ever reading or ends after its most recent one. +export async function getPeriodConsumption(meterId, periodStart, periodEnd) { + const startPoint = await interpolatedValueAtDate(meterId, periodStart) + const endPoint = await interpolatedValueAtDate(meterId, periodEnd) + + if (!startPoint || !endPoint) { + const first = (await readingOnOrAfter(meterId, periodStart)) || (await readingOnOrBefore(meterId, periodStart)) + const last = await readingOnOrBefore(meterId, periodEnd) + if (!first || !last || last.reading_date <= first.reading_date) { + return { consumption: null, first, last, has_data: false } + } + const rawConsumption = Number(last.reading_value) - Number(first.reading_value) + const consumption = await convertMeterConsumption(meterId, rawConsumption) + return { consumption, first, last, has_data: true } } - const rawConsumption = Number(last.reading_value) - Number(first.reading_value) + + const rawConsumption = endPoint.value - startPoint.value const consumption = await convertMeterConsumption(meterId, rawConsumption) - return { consumption, first, last, has_data: true } + return { consumption, first: startPoint.before, last: endPoint.after, has_data: true } } // Trailing average daily consumption as of the meter's latest reading,