diff --git a/backend/src/db.js b/backend/src/db.js index c8024cd..b30dd65 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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 ( diff --git a/backend/src/lib/cost-calc.js b/backend/src/lib/cost-calc.js index 7ce0d41..26372e4 100644 --- a/backend/src/lib/cost-calc.js +++ b/backend/src/lib/cost-calc.js @@ -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 } } diff --git a/backend/src/routes/meters.js b/backend/src/routes/meters.js index d1c602d..83ca5a9 100644 --- a/backend/src/routes/meters.js +++ b/backend/src/routes/meters.js @@ -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) diff --git a/backend/src/routes/readings.js b/backend/src/routes/readings.js index 7543e89..68e2ee1 100644 --- a/backend/src/routes/readings.js +++ b/backend/src/routes/readings.js @@ -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 diff --git a/frontend/src/pages/MeterDetail.tsx b/frontend/src/pages/MeterDetail.tsx index df0bc13..e46ed62 100644 --- a/frontend/src/pages/MeterDetail.tsx +++ b/frontend/src/pages/MeterDetail.tsx @@ -96,7 +96,7 @@ export default function MeterDetail() {
{meter.category_name}
Category
-
{formatUnits(meter.latest_reading_value ? Number(meter.latest_reading_value) : null, meter.unit_label)}
+
{formatUnits(meter.latest_reading_value ? Number(meter.latest_reading_value) : null, meter.reading_unit_label)}
Latest reading
{currentTariff?.tariff_name || '—'}
Current tariff
@@ -184,7 +184,7 @@ export default function MeterDetail() { {meter.recent_readings.map(r => ( {new Date(r.reading_date).toLocaleDateString('en-GB')} - {Number(r.reading_value).toLocaleString()} {meter.unit_label} + {Number(r.reading_value).toLocaleString()} {meter.reading_unit_label} {r.recorded_by || '—'} {r.notes || '—'} diff --git a/frontend/src/pages/Meters.tsx b/frontend/src/pages/Meters.tsx index 0ee8ad2..5bf3916 100644 --- a/frontend/src/pages/Meters.tsx +++ b/frontend/src/pages/Meters.tsx @@ -9,6 +9,7 @@ import * as api from '../api' const emptyForm = { id: 0, category_id: 0, parent_meter_id: '' as number | '', name: '', location: '', serial_number: '', install_date: '', notes: '', active: true, + gas_volume_unit: '' as 'm3' | 'ft3' | '', gas_calorific_value: '' as number | '', } export default function Meters() { @@ -52,6 +53,7 @@ export default function Meters() { 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 || '', 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) } @@ -62,6 +64,7 @@ export default function Meters() { setSaving(true) setError(null) try { + const isGas = categories.find(c => c.id === form.category_id)?.key === 'gas' const body = { category_id: form.category_id, parent_meter_id: form.parent_meter_id || null, @@ -71,6 +74,8 @@ export default function Meters() { install_date: form.install_date || null, notes: form.notes || null, 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) if (imageFile) await api.uploadMeterImage(saved.id, imageFile) @@ -140,7 +145,7 @@ export default function Meters() {
- {formatUnits(m.latest_reading_value ? Number(m.latest_reading_value) : null, m.unit_label)} + {formatUnits(m.latest_reading_value ? Number(m.latest_reading_value) : null, m.reading_unit_label)} {m.latest_reading_date ? new Date(m.latest_reading_date).toLocaleDateString('en-GB') : 'no readings'}
{canManage && ( @@ -165,7 +170,15 @@ export default function Meters() {
- { + 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 => )}
@@ -182,6 +195,31 @@ export default function Meters() { + {categories.find(c => c.id === form.category_id)?.key === 'gas' && ( +
+
+ + +
+
+ + setForm({ ...form, gas_calorific_value: e.target.value === '' ? '' : parseFloat(e.target.value) })} /> +
+
+ )} + {categories.find(c => c.id === form.category_id)?.key === 'gas' && ( +
+ 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. +
+ )} +
diff --git a/frontend/src/pages/Readings.tsx b/frontend/src/pages/Readings.tsx index 90a0c5b..27b39c9 100644 --- a/frontend/src/pages/Readings.tsx +++ b/frontend/src/pages/Readings.tsx @@ -90,7 +90,7 @@ export default function Readings() {
- + setValue(e.target.value)} placeholder="e.g. 45231.5" />
@@ -107,7 +107,7 @@ export default function Readings() {
{selectedMeter?.latest_reading_value && (
- 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')}
)}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index dc54925..98225e1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -37,6 +37,9 @@ export interface Meter { category_name: string category_key: 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_name: string | null name: string