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:
parent
5711e11a07
commit
0b9ac16449
1 changed files with 46 additions and 10 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue