Interpolate period boundaries for historical consumption reports

Reports previously used the raw delta between whichever real readings
happened to fall nearest a period's start/end, un-prorated — accurate
only when readings land close to calendar boundaries. With sparse
readings this misattributes days on either side to the neighbouring
period instead.

getPeriodConsumption() now interpolates the meter's value at each
boundary between the readings bracketing it (linear by day-count),
then diffs those — equivalent to a "daily rate carried forward until
the next reading" spreadsheet approach, generalised across however
many sparse-reading gaps a period spans. Falls back to the old
nearest-reading behaviour when a boundary can't be interpolated (period
starts before the first-ever reading or ends after the latest one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-28 13:51:10 +00:00
parent 5711e11a07
commit 0b9ac16449

View file

@ -81,19 +81,55 @@ async function convertMeterConsumption(meterId, rawConsumption) {
return rawConsumption return rawConsumption
} }
// Consumption for a meter over [periodStart, periodEnd] (inclusive), bracketing // Estimates the meter's cumulative reading value AT an arbitrary date by
// the boundaries with the nearest available readings — meters get one manual // linearly interpolating between the two real readings bracketing it — the
// cumulative reading, not necessarily one exactly on the period edge. // same "average daily rate carried forward until the next reading" logic a
export async function getPeriodConsumption(meterId, periodStart, periodEnd) { // manual spreadsheet would use, generalised so a whole reporting period's
const first = (await readingOnOrAfter(meterId, periodStart)) || (await readingOnOrBefore(meterId, periodStart)) // consumption can span several sparse readings rather than being lumped onto
const last = await readingOnOrBefore(meterId, periodEnd) // 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) { const totalDays = daysBetween(before.reading_date, after.reading_date)
return { consumption: null, first, last, has_data: false } 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) 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, // Trailing average daily consumption as of the meter's latest reading,