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

@ -96,7 +96,7 @@ export default function MeterDetail() {
<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">{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>
<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 => (
<tr key={r.id}>
<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.notes || '—'}</td>
</tr>

View file

@ -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() {
</div>
</div>
<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>
</div>
{canManage && (
@ -165,7 +170,15 @@ export default function Meters() {
</div>
<div className="field">
<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>)}
</select>
</div>
@ -182,6 +195,31 @@ export default function Meters() {
</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">
<label>Parent meter (sub-metering)</label>

View file

@ -90,7 +90,7 @@ export default function Readings() {
</select>
</div>
<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" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
@ -107,7 +107,7 @@ export default function Readings() {
</div>
{selectedMeter?.latest_reading_value && (
<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>

View file

@ -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