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:
jtricerolph 2026-07-28 13:25:03 +00:00
parent 23d8009e00
commit 67dd943cdb
8 changed files with 140 additions and 16 deletions

View file

@ -47,6 +47,13 @@ export async function initDb() {
ALTER TABLE meters ADD COLUMN IF NOT EXISTS mqtt_topic TEXT; 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; 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 -- 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) -- reading across TOU rate windows when there's no device data (v1: manual only)
CREATE TABLE IF NOT EXISTS tariffs ( CREATE TABLE IF NOT EXISTS tariffs (

View file

@ -47,6 +47,40 @@ async function latestReading(meterId) {
return rows[0] || null 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 // Consumption for a meter over [periodStart, periodEnd] (inclusive), bracketing
// the boundaries with the nearest available readings — meters get one manual // the boundaries with the nearest available readings — meters get one manual
// cumulative reading, not necessarily one exactly on the period edge. // 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) { if (!first || !last || last.reading_date <= first.reading_date) {
return { consumption: null, first, last, has_data: false } 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 } 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) const days = daysBetween(before.reading_date, latest.reading_date)
if (days <= 0) return { daily_rate: null, latest } 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 } return { daily_rate: consumption / days, latest, window_start_reading: before }
} }

View file

@ -4,9 +4,20 @@ import { createWriteStream } from 'fs'
import { mkdir, unlink } from 'fs/promises' import { mkdir, unlink } from 'fs/promises'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { extname, join } from 'path' 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'] 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) { export async function meterRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth) app.addHook('preHandler', requireAuth)
@ -21,7 +32,7 @@ export async function meterRoutes(app, opts) {
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '' const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
const { rows } = await pool.query( 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, p.name AS parent_name,
lr.reading_value AS latest_reading_value, lr.reading_date AS latest_reading_date lr.reading_value AS latest_reading_value, lr.reading_date AS latest_reading_date
FROM meters m FROM meters m
@ -41,7 +52,7 @@ export async function meterRoutes(app, opts) {
// GET /api/meters/:id — detail: meter + children + tariff history + recent readings // GET /api/meters/:id — detail: meter + children + tariff history + recent readings
app.get('/api/meters/:id', async (req, reply) => { app.get('/api/meters/:id', async (req, reply) => {
const { rows } = await pool.query( 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 FROM meters m
JOIN meter_categories c ON c.id = m.category_id JOIN meter_categories c ON c.id = m.category_id
LEFT JOIN meters p ON p.id = m.parent_meter_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 || {} const b = req.body || {}
if (!b.name || !b.category_id) return reply.status(400).send({ error: 'name and category_id required' }) if (!b.name || !b.category_id) return reply.status(400).send({ error: 'name and category_id required' })
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO meters (category_id, parent_meter_id, name, location, serial_number, install_date, notes) `INSERT INTO meters (category_id, parent_meter_id, name, location, serial_number, install_date, notes, mqtt_topic,
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, gas_volume_unit, gas_calorific_value)
[b.category_id, b.parent_meter_id || null, b.name, b.location || null, b.serial_number || null, b.install_date || null, b.notes || null] 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] return rows[0]
}) })
@ -98,8 +115,9 @@ export async function meterRoutes(app, opts) {
} }
const { rows } = await pool.query( const { rows } = await pool.query(
`UPDATE meters SET category_id = $1, parent_meter_id = $2, name = $3, location = $4, `UPDATE meters SET category_id = $1, parent_meter_id = $2, name = $3, location = $4,
serial_number = $5, install_date = $6, notes = $7, active = $8 serial_number = $5, install_date = $6, notes = $7, active = $8, mqtt_topic = $9,
WHERE id = $9 RETURNING *`, gas_volume_unit = $10, gas_calorific_value = $11
WHERE id = $12 RETURNING *`,
[ [
b.category_id ?? m.category_id, b.category_id ?? m.category_id,
b.parent_meter_id !== undefined ? b.parent_meter_id : m.parent_meter_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.install_date !== undefined ? b.install_date : m.install_date,
b.notes !== undefined ? b.notes : m.notes, b.notes !== undefined ? b.notes : m.notes,
b.active ?? m.active, 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, req.params.id,
] ]
) )
if (b.mqtt_topic !== undefined) await refreshTopicMap()
return rows[0] 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 // POST /api/meters/:id/image — multipart upload, cashup-style pattern
app.post('/api/meters/:id/image', { preHandler: requireCap('meters') }, async (req, reply) => { app.post('/api/meters/:id/image', { preHandler: requireCap('meters') }, async (req, reply) => {
const meterId = parseInt(req.params.id) const meterId = parseInt(req.params.id)

View file

@ -7,6 +7,14 @@ import { extname, join } from 'path'
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] 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) { export async function readingRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth) app.addHook('preHandler', requireAuth)
@ -23,7 +31,7 @@ export async function readingRoutes(app, opts) {
params.push(Math.min(parseInt(limit) || 100, 500)) params.push(Math.min(parseInt(limit) || 100, 500))
const { rows } = await pool.query( 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 FROM readings r
JOIN meters m ON m.id = r.meter_id JOIN meters m ON m.id = r.meter_id
JOIN meter_categories c ON c.id = m.category_id JOIN meter_categories c ON c.id = m.category_id

View file

@ -96,7 +96,7 @@ export default function MeterDetail() {
<div className="stats-strip"> <div className="stats-strip">
<div className="stat-box"><div className="stat-value">{meter.category_name}</div><div className="stat-label">Category</div></div> <div className="stat-box"><div className="stat-value">{meter.category_name}</div><div className="stat-label">Category</div></div>
<div className="stat-box"> <div className="stat-box">
<div className="stat-value">{formatUnits(meter.latest_reading_value ? Number(meter.latest_reading_value) : null, meter.unit_label)}</div> <div className="stat-value">{formatUnits(meter.latest_reading_value ? Number(meter.latest_reading_value) : null, meter.reading_unit_label)}</div>
<div className="stat-label">Latest reading</div> <div className="stat-label">Latest reading</div>
</div> </div>
<div className="stat-box"><div className="stat-value">{currentTariff?.tariff_name || '—'}</div><div className="stat-label">Current tariff</div></div> <div className="stat-box"><div className="stat-value">{currentTariff?.tariff_name || '—'}</div><div className="stat-label">Current tariff</div></div>
@ -184,7 +184,7 @@ export default function MeterDetail() {
{meter.recent_readings.map(r => ( {meter.recent_readings.map(r => (
<tr key={r.id}> <tr key={r.id}>
<td>{new Date(r.reading_date).toLocaleDateString('en-GB')}</td> <td>{new Date(r.reading_date).toLocaleDateString('en-GB')}</td>
<td className="num">{Number(r.reading_value).toLocaleString()} {meter.unit_label}</td> <td className="num">{Number(r.reading_value).toLocaleString()} {meter.reading_unit_label}</td>
<td>{r.recorded_by || '—'}</td> <td>{r.recorded_by || '—'}</td>
<td>{r.notes || '—'}</td> <td>{r.notes || '—'}</td>
</tr> </tr>

View file

@ -9,6 +9,7 @@ import * as api from '../api'
const emptyForm = { const emptyForm = {
id: 0, category_id: 0, parent_meter_id: '' as number | '', name: '', location: '', id: 0, category_id: 0, parent_meter_id: '' as number | '', name: '', location: '',
serial_number: '', install_date: '', notes: '', active: true, serial_number: '', install_date: '', notes: '', active: true,
gas_volume_unit: '' as 'm3' | 'ft3' | '', gas_calorific_value: '' as number | '',
} }
export default function Meters() { export default function Meters() {
@ -52,6 +53,7 @@ export default function Meters() {
name: m.name, location: m.location || '', serial_number: m.serial_number || '', name: m.name, location: m.location || '', serial_number: m.serial_number || '',
install_date: m.install_date ? m.install_date.slice(0, 10) : '', notes: m.notes || '', install_date: m.install_date ? m.install_date.slice(0, 10) : '', notes: m.notes || '',
active: m.active, active: m.active,
gas_volume_unit: m.gas_volume_unit || '', gas_calorific_value: m.gas_calorific_value != null ? Number(m.gas_calorific_value) : '',
}) })
setImageFile(null) setImageFile(null)
} }
@ -62,6 +64,7 @@ export default function Meters() {
setSaving(true) setSaving(true)
setError(null) setError(null)
try { try {
const isGas = categories.find(c => c.id === form.category_id)?.key === 'gas'
const body = { const body = {
category_id: form.category_id, category_id: form.category_id,
parent_meter_id: form.parent_meter_id || null, parent_meter_id: form.parent_meter_id || null,
@ -71,6 +74,8 @@ export default function Meters() {
install_date: form.install_date || null, install_date: form.install_date || null,
notes: form.notes || null, notes: form.notes || null,
active: form.active, active: form.active,
gas_volume_unit: isGas && form.gas_volume_unit ? form.gas_volume_unit : null,
gas_calorific_value: isGas && form.gas_calorific_value !== '' ? form.gas_calorific_value : null,
} }
const saved = form.id ? await api.updateMeter(form.id, body) : await api.createMeter(body) const saved = form.id ? await api.updateMeter(form.id, body) : await api.createMeter(body)
if (imageFile) await api.uploadMeterImage(saved.id, imageFile) if (imageFile) await api.uploadMeterImage(saved.id, imageFile)
@ -140,7 +145,7 @@ export default function Meters() {
</div> </div>
</div> </div>
<div className="meter-card-side"> <div className="meter-card-side">
<span className="meter-reading-val">{formatUnits(m.latest_reading_value ? Number(m.latest_reading_value) : null, m.unit_label)}</span> <span className="meter-reading-val">{formatUnits(m.latest_reading_value ? Number(m.latest_reading_value) : null, m.reading_unit_label)}</span>
<span className="meter-reading-date">{m.latest_reading_date ? new Date(m.latest_reading_date).toLocaleDateString('en-GB') : 'no readings'}</span> <span className="meter-reading-date">{m.latest_reading_date ? new Date(m.latest_reading_date).toLocaleDateString('en-GB') : 'no readings'}</span>
</div> </div>
{canManage && ( {canManage && (
@ -165,7 +170,15 @@ export default function Meters() {
</div> </div>
<div className="field"> <div className="field">
<label>Category</label> <label>Category</label>
<select value={form.category_id} onChange={e => setForm({ ...form, category_id: parseInt(e.target.value), parent_meter_id: '' })}> <select value={form.category_id} onChange={e => {
const category_id = parseInt(e.target.value)
const isGas = categories.find(c => c.id === category_id)?.key === 'gas'
setForm({
...form, category_id, parent_meter_id: '',
gas_volume_unit: isGas ? form.gas_volume_unit : '',
gas_calorific_value: isGas ? form.gas_calorific_value : '',
})
}}>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} {categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select> </select>
</div> </div>
@ -182,6 +195,31 @@ export default function Meters() {
</div> </div>
</div> </div>
{categories.find(c => c.id === form.category_id)?.key === 'gas' && (
<div className="field-row">
<div className="field">
<label>Meter reads in</label>
<select value={form.gas_volume_unit} onChange={e => setForm({ ...form, gas_volume_unit: e.target.value as 'm3' | 'ft3' | '' })}>
<option value="">Select</option>
<option value="m3">m³ (metric)</option>
<option value="ft3">ft³ (imperial)</option>
</select>
</div>
<div className="field">
<label>Calorific value (MJ/m³)</label>
<input type="number" step="0.001" placeholder="e.g. 39.5 (from latest bill)" value={form.gas_calorific_value}
onChange={e => setForm({ ...form, gas_calorific_value: e.target.value === '' ? '' : parseFloat(e.target.value) })} />
</div>
</div>
)}
{categories.find(c => c.id === form.category_id)?.key === 'gas' && (
<div className="muted" style={{ marginTop: -8, marginBottom: 14, fontSize: '0.85em' }}>
Readings for this meter are entered as raw volume off the dial cost calculations convert to
kWh using the standard gas-billing formula. Update the calorific value from time to time from
a recent bill; it drifts slightly and defaults to a typical UK average (39.5) if left blank.
</div>
)}
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">
<label>Parent meter (sub-metering)</label> <label>Parent meter (sub-metering)</label>

View file

@ -90,7 +90,7 @@ export default function Readings() {
</select> </select>
</div> </div>
<div className="field" style={{ marginBottom: 0 }}> <div className="field" style={{ marginBottom: 0 }}>
<label>Reading value{selectedMeter ? ` (${selectedMeter.unit_label})` : ''}</label> <label>Reading value{selectedMeter ? ` (${selectedMeter.reading_unit_label})` : ''}</label>
<input type="number" step="0.001" value={value} onChange={e => setValue(e.target.value)} placeholder="e.g. 45231.5" /> <input type="number" step="0.001" value={value} onChange={e => setValue(e.target.value)} placeholder="e.g. 45231.5" />
</div> </div>
<div className="field" style={{ marginBottom: 0 }}> <div className="field" style={{ marginBottom: 0 }}>
@ -107,7 +107,7 @@ export default function Readings() {
</div> </div>
{selectedMeter?.latest_reading_value && ( {selectedMeter?.latest_reading_value && (
<div className="field-hint" style={{ marginTop: 6 }}> <div className="field-hint" style={{ marginTop: 6 }}>
Previous reading: {Number(selectedMeter.latest_reading_value).toLocaleString()} {selectedMeter.unit_label} on {new Date(selectedMeter.latest_reading_date!).toLocaleDateString('en-GB')} Previous reading: {Number(selectedMeter.latest_reading_value).toLocaleString()} {selectedMeter.reading_unit_label} on {new Date(selectedMeter.latest_reading_date!).toLocaleDateString('en-GB')}
</div> </div>
)} )}
</div> </div>

View file

@ -37,6 +37,9 @@ export interface Meter {
category_name: string category_name: string
category_key: string category_key: string
unit_label: string unit_label: string
reading_unit_label: string
gas_volume_unit: 'm3' | 'ft3' | null
gas_calorific_value: number | null
parent_meter_id: number | null parent_meter_id: number | null
parent_name: string | null parent_name: string | null
name: string name: string