Convert gas meter volume readings to kWh for cost calculations
Gas meters are read in raw volume (ft3 on our imperial meter, m3 on newer metric ones), never kWh — but tariff rates and CCL/RAB levy are all p/kWh. Add gas_volume_unit + gas_calorific_value to meters, and convert reading deltas to kWh in cost-calc.js using the standard UK gas-billing formula (matches the exact calculation printed on supplier invoices) before any cost math runs. Also fixes meter/readings pages showing raw gas dial readings mislabeled as "kWh" — they now show the actual physical unit (ft³/m³) via a new reading_unit_label, while computed consumption stays correctly labeled kWh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
23d8009e00
commit
67dd943cdb
8 changed files with 140 additions and 16 deletions
|
|
@ -47,6 +47,13 @@ export async function initDb() {
|
|||
ALTER TABLE meters ADD COLUMN IF NOT EXISTS mqtt_topic TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS meters_mqtt_topic_uidx ON meters (mqtt_topic) WHERE mqtt_topic IS NOT NULL;
|
||||
|
||||
-- gas meters read raw volume off the dial (m3 on newer metric meters, ft3 on
|
||||
-- older imperial ones like ours), never kWh directly — these two columns let
|
||||
-- cost-calc.js convert a volume reading to billed kWh using the same
|
||||
-- industry formula suppliers print on their invoices. NULL on non-gas meters.
|
||||
ALTER TABLE meters ADD COLUMN IF NOT EXISTS gas_volume_unit TEXT;
|
||||
ALTER TABLE meters ADD COLUMN IF NOT EXISTS gas_calorific_value NUMERIC(6,3);
|
||||
|
||||
-- fallback_split_pct e.g. {"day":60,"night":40} — used to split one cumulative
|
||||
-- reading across TOU rate windows when there's no device data (v1: manual only)
|
||||
CREATE TABLE IF NOT EXISTS tariffs (
|
||||
|
|
|
|||
|
|
@ -47,6 +47,40 @@ async function latestReading(meterId) {
|
|||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Gas meters are read in raw volume (m3 on newer metric meters, ft3 on older
|
||||
// imperial ones) — never kWh directly. These constants mirror the standard
|
||||
// UK gas-billing formula suppliers print on their invoices: metric meters
|
||||
// skip the imperial multiplier, imperial ones apply it first.
|
||||
const GAS_VOLUME_CORRECTION_FACTOR = 1.02264 // fixed industry constant, corrects for temperature/pressure
|
||||
const GAS_IMPERIAL_TO_METRIC_FACTOR = 2.83 // ft3 meters only
|
||||
const GAS_DEFAULT_CALORIFIC_VALUE = 39.5 // typical UK natural gas CV (MJ/m3), used until a meter's own value is set from a bill
|
||||
|
||||
function gasVolumeToKwh(volume, unit, calorificValue) {
|
||||
const cv = Number(calorificValue) || GAS_DEFAULT_CALORIFIC_VALUE
|
||||
const base = unit === 'ft3' ? volume * GAS_IMPERIAL_TO_METRIC_FACTOR : volume
|
||||
return (base * cv * GAS_VOLUME_CORRECTION_FACTOR) / 3.6
|
||||
}
|
||||
|
||||
async function getMeterGasConfig(meterId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.key AS category_key, m.gas_volume_unit, m.gas_calorific_value
|
||||
FROM meters m JOIN meter_categories c ON c.id = m.category_id WHERE m.id = $1`,
|
||||
[meterId]
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Converts a raw reading-delta to the category's billed unit — a no-op for
|
||||
// every category except gas meters with a volume unit configured, where the
|
||||
// raw m3/ft3 delta becomes kWh so it lines up with tariff rates (p/kWh).
|
||||
async function convertMeterConsumption(meterId, rawConsumption) {
|
||||
const gasConfig = await getMeterGasConfig(meterId)
|
||||
if (gasConfig?.category_key === 'gas' && gasConfig.gas_volume_unit) {
|
||||
return gasVolumeToKwh(rawConsumption, gasConfig.gas_volume_unit, gasConfig.gas_calorific_value)
|
||||
}
|
||||
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.
|
||||
|
|
@ -57,7 +91,8 @@ export async function getPeriodConsumption(meterId, periodStart, periodEnd) {
|
|||
if (!first || !last || last.reading_date <= first.reading_date) {
|
||||
return { consumption: null, first, last, has_data: false }
|
||||
}
|
||||
const consumption = Number(last.reading_value) - Number(first.reading_value)
|
||||
const rawConsumption = Number(last.reading_value) - Number(first.reading_value)
|
||||
const consumption = await convertMeterConsumption(meterId, rawConsumption)
|
||||
return { consumption, first, last, has_data: true }
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +115,8 @@ export async function getTrailingDailyRate(meterId, windowDays) {
|
|||
const days = daysBetween(before.reading_date, latest.reading_date)
|
||||
if (days <= 0) return { daily_rate: null, latest }
|
||||
|
||||
const consumption = Number(latest.reading_value) - Number(before.reading_value)
|
||||
const rawConsumption = Number(latest.reading_value) - Number(before.reading_value)
|
||||
const consumption = await convertMeterConsumption(meterId, rawConsumption)
|
||||
return { daily_rate: consumption / days, latest, window_start_reading: before }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,20 @@ import { createWriteStream } from 'fs'
|
|||
import { mkdir, unlink } from 'fs/promises'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { extname, join } from 'path'
|
||||
import { refreshTopicMap } from '../lib/mqtt.js'
|
||||
import { getMeterHistorySeries } from '../lib/history.js'
|
||||
|
||||
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
|
||||
// A gas meter's raw dial reads volume (ft3/m3), never the category's billed
|
||||
// unit (kWh) — everywhere a raw reading_value is displayed it needs this
|
||||
// label instead of c.unit_label, which is correct only for computed consumption.
|
||||
const READING_UNIT_LABEL_SQL = `
|
||||
CASE WHEN c.key = 'gas' AND m.gas_volume_unit IS NOT NULL
|
||||
THEN (CASE WHEN m.gas_volume_unit = 'ft3' THEN 'ft³' ELSE 'm³' END)
|
||||
ELSE c.unit_label END AS reading_unit_label
|
||||
`
|
||||
|
||||
export async function meterRoutes(app, opts) {
|
||||
const UPLOADS_DIR = opts.uploadsDir
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
|
@ -21,7 +32,7 @@ export async function meterRoutes(app, opts) {
|
|||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT m.*, c.name AS category_name, c.key AS category_key, c.unit_label,
|
||||
`SELECT m.*, c.name AS category_name, c.key AS category_key, c.unit_label, ${READING_UNIT_LABEL_SQL},
|
||||
p.name AS parent_name,
|
||||
lr.reading_value AS latest_reading_value, lr.reading_date AS latest_reading_date
|
||||
FROM meters m
|
||||
|
|
@ -41,7 +52,7 @@ export async function meterRoutes(app, opts) {
|
|||
// GET /api/meters/:id — detail: meter + children + tariff history + recent readings
|
||||
app.get('/api/meters/:id', async (req, reply) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT m.*, c.name AS category_name, c.key AS category_key, c.unit_label, p.name AS parent_name
|
||||
`SELECT m.*, c.name AS category_name, c.key AS category_key, c.unit_label, ${READING_UNIT_LABEL_SQL}, p.name AS parent_name
|
||||
FROM meters m
|
||||
JOIN meter_categories c ON c.id = m.category_id
|
||||
LEFT JOIN meters p ON p.id = m.parent_meter_id
|
||||
|
|
@ -81,10 +92,16 @@ export async function meterRoutes(app, opts) {
|
|||
const b = req.body || {}
|
||||
if (!b.name || !b.category_id) return reply.status(400).send({ error: 'name and category_id required' })
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO meters (category_id, parent_meter_id, name, location, serial_number, install_date, notes)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[b.category_id, b.parent_meter_id || null, b.name, b.location || null, b.serial_number || null, b.install_date || null, b.notes || null]
|
||||
`INSERT INTO meters (category_id, parent_meter_id, name, location, serial_number, install_date, notes, mqtt_topic,
|
||||
gas_volume_unit, gas_calorific_value)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
|
||||
[
|
||||
b.category_id, b.parent_meter_id || null, b.name, b.location || null, b.serial_number || null,
|
||||
b.install_date || null, b.notes || null, b.mqtt_topic || null,
|
||||
b.gas_volume_unit || null, b.gas_calorific_value || null,
|
||||
]
|
||||
)
|
||||
if (b.mqtt_topic) await refreshTopicMap()
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
|
|
@ -98,8 +115,9 @@ export async function meterRoutes(app, opts) {
|
|||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE meters SET category_id = $1, parent_meter_id = $2, name = $3, location = $4,
|
||||
serial_number = $5, install_date = $6, notes = $7, active = $8
|
||||
WHERE id = $9 RETURNING *`,
|
||||
serial_number = $5, install_date = $6, notes = $7, active = $8, mqtt_topic = $9,
|
||||
gas_volume_unit = $10, gas_calorific_value = $11
|
||||
WHERE id = $12 RETURNING *`,
|
||||
[
|
||||
b.category_id ?? m.category_id,
|
||||
b.parent_meter_id !== undefined ? b.parent_meter_id : m.parent_meter_id,
|
||||
|
|
@ -109,12 +127,26 @@ export async function meterRoutes(app, opts) {
|
|||
b.install_date !== undefined ? b.install_date : m.install_date,
|
||||
b.notes !== undefined ? b.notes : m.notes,
|
||||
b.active ?? m.active,
|
||||
b.mqtt_topic !== undefined ? b.mqtt_topic : m.mqtt_topic,
|
||||
b.gas_volume_unit !== undefined ? b.gas_volume_unit : m.gas_volume_unit,
|
||||
b.gas_calorific_value !== undefined ? b.gas_calorific_value : m.gas_calorific_value,
|
||||
req.params.id,
|
||||
]
|
||||
)
|
||||
if (b.mqtt_topic !== undefined) await refreshTopicMap()
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// GET /api/meters/:id/history?from=&to= — stitched telemetry + readings series
|
||||
app.get('/api/meters/:id/history', async (req, reply) => {
|
||||
const { from, to } = req.query
|
||||
if (!from || !to) return reply.status(400).send({ error: 'from and to are required (YYYY-MM-DD)' })
|
||||
const { rows } = await pool.query('SELECT id FROM meters WHERE id = $1', [req.params.id])
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Meter not found' })
|
||||
const series = await getMeterHistorySeries(req.params.id, from, to)
|
||||
return { meter_id: Number(req.params.id), from, to, series }
|
||||
})
|
||||
|
||||
// POST /api/meters/:id/image — multipart upload, cashup-style pattern
|
||||
app.post('/api/meters/:id/image', { preHandler: requireCap('meters') }, async (req, reply) => {
|
||||
const meterId = parseInt(req.params.id)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ import { extname, join } from 'path'
|
|||
|
||||
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
|
||||
// Mirrors routes/meters.js — a gas meter's raw reading is volume (ft3/m3),
|
||||
// never the category's billed kWh unit.
|
||||
const READING_UNIT_LABEL_SQL = `
|
||||
CASE WHEN c.key = 'gas' AND m.gas_volume_unit IS NOT NULL
|
||||
THEN (CASE WHEN m.gas_volume_unit = 'ft3' THEN 'ft³' ELSE 'm³' END)
|
||||
ELSE c.unit_label END AS unit_label
|
||||
`
|
||||
|
||||
export async function readingRoutes(app, opts) {
|
||||
const UPLOADS_DIR = opts.uploadsDir
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
|
@ -23,7 +31,7 @@ export async function readingRoutes(app, opts) {
|
|||
params.push(Math.min(parseInt(limit) || 100, 500))
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT r.*, m.name AS meter_name, c.unit_label
|
||||
`SELECT r.*, m.name AS meter_name, ${READING_UNIT_LABEL_SQL}
|
||||
FROM readings r
|
||||
JOIN meters m ON m.id = r.meter_id
|
||||
JOIN meter_categories c ON c.id = m.category_id
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue