From 3b261a4d8df9ba469c32f21f557071ab366f1256 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 28 Jul 2026 13:07:20 +0000 Subject: [PATCH] Add Nuclear RAB levy, metering charge and DUoS catch-all to tariff cost model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The June electricity bill showed £252.72 (7.5% of the bill) in charges the cost model didn't account for: Nuclear RAB Levy, Metering Charge, and DUoS Availability/Excess/Reactive. RAB levy and metering charge are modeled directly (same shape as CCL / standing charge); the DUoS charges need kVA capacity and kVArh reactive energy that only the supplier's own meter reads, so they're covered by one adjustable "other network charges" £/month field to update manually from each bill. Co-Authored-By: Claude Sonnet 5 --- backend/src/db.js | 40 ++++++++++++++++++++++++++++++++ backend/src/lib/cost-calc.js | 24 ++++++++++++++++--- backend/src/routes/estimates.js | 8 ++++++- backend/src/routes/internal.js | 13 +++++++++-- backend/src/routes/reports.js | 8 ++++++- backend/src/routes/tariffs.js | 15 ++++++++---- frontend/src/pages/Estimates.tsx | 4 +++- frontend/src/pages/Reports.tsx | 7 ++++++ frontend/src/pages/Tariffs.tsx | 39 ++++++++++++++++++++++++++++--- frontend/src/types.ts | 15 ++++++++++++ 10 files changed, 158 insertions(+), 15 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 20876bf..c8024cd 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -42,6 +42,11 @@ export async function initDb() { CREATE INDEX IF NOT EXISTS meters_category_idx ON meters (category_id); CREATE INDEX IF NOT EXISTS meters_parent_idx ON meters (parent_meter_id); + -- set on any meter fed by an MQTT-publishing device (e.g. an ESPHome sensor's + -- state topic); NULL for manually-read meters, which this never touches + 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; + -- 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 ( @@ -62,6 +67,15 @@ export async function initDb() { CREATE INDEX IF NOT EXISTS tariffs_category_idx ON tariffs (category_id); + -- second per-unit levy (e.g. Nuclear RAB Levy) alongside CCL, plus flat + -- monthly charges bills carry outside consumption/standing: a metering + -- charge and a catch-all for capacity-based network charges (DUoS + -- availability/excess/reactive) that need kVA/kVArh data this app doesn't + -- track — entered as one adjustable £/month figure instead + ALTER TABLE tariffs ADD COLUMN IF NOT EXISTS rab_levy_rate_pence_per_unit NUMERIC(10,4); + ALTER TABLE tariffs ADD COLUMN IF NOT EXISTS metering_charge_pence_per_month NUMERIC(10,4) NOT NULL DEFAULT 0; + ALTER TABLE tariffs ADD COLUMN IF NOT EXISTS other_charges_pence_per_month NUMERIC(10,4) NOT NULL DEFAULT 0; + -- non-TOU tariff = single window covering all days/hours CREATE TABLE IF NOT EXISTS tariff_rate_windows ( id SERIAL PRIMARY KEY, @@ -105,6 +119,29 @@ export async function initDb() { ); CREATE INDEX IF NOT EXISTS readings_meter_date_idx ON readings (meter_id, reading_date DESC); + + -- raw MQTT telemetry, kept briefly for visibility/debugging only — NOT the + -- official reading log (that's the readings table); one row per device publish + CREATE TABLE IF NOT EXISTS meter_telemetry_raw ( + id SERIAL PRIMARY KEY, + meter_id INT NOT NULL REFERENCES meters(id) ON DELETE CASCADE, + value NUMERIC(14,3) NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS telemetry_raw_meter_time_idx ON meter_telemetry_raw (meter_id, recorded_at DESC); + + -- hourly downsample of the above (last raw value seen in the hour, since the + -- telemetry value is a monotonic cumulative totalizer — last-value-in-bucket, + -- not an average), independent retention from the raw table + CREATE TABLE IF NOT EXISTS meter_telemetry_hourly ( + id SERIAL PRIMARY KEY, + meter_id INT NOT NULL REFERENCES meters(id) ON DELETE CASCADE, + bucket_start TIMESTAMPTZ NOT NULL, + value NUMERIC(14,3) NOT NULL, + sample_count INT NOT NULL DEFAULT 1, + UNIQUE (meter_id, bucket_start) + ); + CREATE INDEX IF NOT EXISTS telemetry_hourly_meter_time_idx ON meter_telemetry_hourly (meter_id, bucket_start DESC); `) await seedDefaults() @@ -127,6 +164,9 @@ async function seedDefaults() { const defaults = { estimate_trailing_days: 30, // global default trailing-average window (days), 7 | 14 | 30 + telemetry_raw_retention_hours: 336, // 14 days + telemetry_hourly_retention_days: 60, + telemetry_daily_schedule_time: '03:15', } for (const [key, value] of Object.entries(defaults)) { await pool.query( diff --git a/backend/src/lib/cost-calc.js b/backend/src/lib/cost-calc.js index 6e110f7..7ce0d41 100644 --- a/backend/src/lib/cost-calc.js +++ b/backend/src/lib/cost-calc.js @@ -153,7 +153,10 @@ export async function getRateWindows(tariffId) { // the Directors report's dry/wet forecast. export function computeCost({ tariff, windows, consumption, daysInPeriod }) { if (!tariff || consumption == null) { - return { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0, split: null } + return { + usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0, + metering_cost_pence: 0, other_charges_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0, split: null, + } } let usageCostPence = 0 @@ -178,8 +181,17 @@ export function computeCost({ tariff, windows, consumption, daysInPeriod }) { const cclCostPence = (!tariff.ccl_exempt && tariff.ccl_rate_pence_per_unit) ? consumption * Number(tariff.ccl_rate_pence_per_unit) : 0 + const rabLevyCostPence = tariff.rab_levy_rate_pence_per_unit + ? consumption * Number(tariff.rab_levy_rate_pence_per_unit) + : 0 + // Flat monthly charges (metering charge, and a catch-all for DUoS + // capacity/reactive charges this app can't compute from meter data alone) + // pro-rated at 1/30th per day of the period, matching how suppliers apply + // them to a non-calendar-month billing period. + const meteringCostPence = Number(tariff.metering_charge_pence_per_month || 0) / 30 * daysInPeriod + const otherChargesCostPence = Number(tariff.other_charges_pence_per_month || 0) / 30 * daysInPeriod - const subtotalPence = usageCostPence + standingCostPence + cclCostPence + const subtotalPence = usageCostPence + standingCostPence + cclCostPence + rabLevyCostPence + meteringCostPence + otherChargesCostPence const vatPence = subtotalPence * (Number(tariff.vat_rate_pct) / 100) const totalPence = subtotalPence + vatPence @@ -187,6 +199,9 @@ export function computeCost({ tariff, windows, consumption, daysInPeriod }) { usage_cost_pence: usageCostPence, standing_cost_pence: standingCostPence, ccl_cost_pence: cclCostPence, + rab_levy_cost_pence: rabLevyCostPence, + metering_cost_pence: meteringCostPence, + other_charges_cost_pence: otherChargesCostPence, subtotal_pence: subtotalPence, vat_pence: vatPence, total_pence: totalPence, @@ -194,7 +209,10 @@ export function computeCost({ tariff, windows, consumption, daysInPeriod }) { } } -const ZERO_TOTALS = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0 } +const ZERO_TOTALS = { + usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0, + metering_cost_pence: 0, other_charges_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0, +} // Cost a single amount of consumption over [rangeStart, rangeEnd], splitting it // pro-rata (by day count) across every tariff segment active in that range. Used diff --git a/backend/src/routes/estimates.js b/backend/src/routes/estimates.js index 9536373..83bd90d 100644 --- a/backend/src/routes/estimates.js +++ b/backend/src/routes/estimates.js @@ -47,8 +47,14 @@ export async function estimateRoutes(app) { usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence, standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence, ccl_cost_pence: acc.ccl_cost_pence + r.ccl_cost_pence, + rab_levy_cost_pence: acc.rab_levy_cost_pence + r.rab_levy_cost_pence, + metering_cost_pence: acc.metering_cost_pence + r.metering_cost_pence, + other_charges_cost_pence: acc.other_charges_cost_pence + r.other_charges_cost_pence, vat_pence: acc.vat_pence + r.vat_pence, - }), { total_pence: 0, usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0 }) + }), { + total_pence: 0, usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, + rab_levy_cost_pence: 0, metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0, + }) return { period: { year, month, start, end }, global_default_window: globalDefault, meters: results, totals } }) diff --git a/backend/src/routes/internal.js b/backend/src/routes/internal.js index f3d996c..0c5818c 100644 --- a/backend/src/routes/internal.js +++ b/backend/src/routes/internal.js @@ -63,11 +63,17 @@ export async function internalRoutes(app) { const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order') const breakdown = [] - const totals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 } + const totals = { + usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0, + metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0, + } for (const cat of categories) { const { rows: meters } = await pool.query('SELECT id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id]) - const catTotals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 } + const catTotals = { + usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0, + metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0, + } for (const m of meters) { const cost = await getMeterCostForPeriod(m.id, start, end) @@ -75,6 +81,9 @@ export async function internalRoutes(app) { catTotals.usage_cost_pence += cost.usage_cost_pence catTotals.standing_cost_pence += cost.standing_cost_pence catTotals.ccl_cost_pence += cost.ccl_cost_pence + catTotals.rab_levy_cost_pence += cost.rab_levy_cost_pence + catTotals.metering_cost_pence += cost.metering_cost_pence + catTotals.other_charges_cost_pence += cost.other_charges_cost_pence catTotals.vat_pence += cost.vat_pence catTotals.total_pence += cost.total_pence } diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js index 8f7f4a6..2c39048 100644 --- a/backend/src/routes/reports.js +++ b/backend/src/routes/reports.js @@ -37,9 +37,15 @@ export async function reportRoutes(app) { usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence, standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence, ccl_cost_pence: acc.ccl_cost_pence + r.ccl_cost_pence, + rab_levy_cost_pence: acc.rab_levy_cost_pence + r.rab_levy_cost_pence, + metering_cost_pence: acc.metering_cost_pence + r.metering_cost_pence, + other_charges_cost_pence: acc.other_charges_cost_pence + r.other_charges_cost_pence, vat_pence: acc.vat_pence + r.vat_pence, total_pence: acc.total_pence + r.total_pence, - }), { consumption: 0, usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0 }) + }), { + consumption: 0, usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, + rab_levy_cost_pence: 0, metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0, total_pence: 0, + }) return { period: { start, end, isCurrent }, meters: results, totals } }) diff --git a/backend/src/routes/tariffs.js b/backend/src/routes/tariffs.js index c3d919b..e37aa9a 100644 --- a/backend/src/routes/tariffs.js +++ b/backend/src/routes/tariffs.js @@ -64,12 +64,14 @@ export async function tariffRoutes(app) { const { rows } = await client.query( `INSERT INTO tariffs (category_id, name, supplier, effective_from, effective_to, standing_charge_pence_per_day, ccl_rate_pence_per_unit, ccl_exempt, - vat_rate_pct, is_time_of_use, fallback_split_pct) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, + vat_rate_pct, is_time_of_use, fallback_split_pct, + rab_levy_rate_pence_per_unit, metering_charge_pence_per_month, other_charges_pence_per_month) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, [ b.category_id, b.name, b.supplier || null, b.effective_from, b.effective_to || null, b.standing_charge_pence_per_day || 0, b.ccl_rate_pence_per_unit || null, b.ccl_exempt === true, b.vat_rate_pct ?? 20, b.is_time_of_use === true, JSON.stringify(b.fallback_split_pct || {}), + b.rab_levy_rate_pence_per_unit || null, b.metering_charge_pence_per_month || 0, b.other_charges_pence_per_month || 0, ] ) const tariff = rows[0] @@ -112,8 +114,10 @@ export async function tariffRoutes(app) { const { rows } = await pool.query( `UPDATE tariffs SET name = $1, supplier = $2, effective_from = $3, effective_to = $4, standing_charge_pence_per_day = $5, ccl_rate_pence_per_unit = $6, ccl_exempt = $7, - vat_rate_pct = $8, is_time_of_use = $9, fallback_split_pct = $10 - WHERE id = $11 RETURNING *`, + vat_rate_pct = $8, is_time_of_use = $9, fallback_split_pct = $10, + rab_levy_rate_pence_per_unit = $11, metering_charge_pence_per_month = $12, + other_charges_pence_per_month = $13 + WHERE id = $14 RETURNING *`, [ b.name ?? t.name, b.supplier !== undefined ? b.supplier : t.supplier, @@ -125,6 +129,9 @@ export async function tariffRoutes(app) { b.vat_rate_pct ?? t.vat_rate_pct, b.is_time_of_use ?? t.is_time_of_use, JSON.stringify(b.fallback_split_pct ?? t.fallback_split_pct), + b.rab_levy_rate_pence_per_unit !== undefined ? b.rab_levy_rate_pence_per_unit : t.rab_levy_rate_pence_per_unit, + b.metering_charge_pence_per_month ?? t.metering_charge_pence_per_month, + b.other_charges_pence_per_month ?? t.other_charges_pence_per_month, req.params.id, ] ) diff --git a/frontend/src/pages/Estimates.tsx b/frontend/src/pages/Estimates.tsx index fc43f8a..3da9b02 100644 --- a/frontend/src/pages/Estimates.tsx +++ b/frontend/src/pages/Estimates.tsx @@ -64,7 +64,7 @@ export default function Estimates() { <>
Projected cost for the current open period ({report.period.start} to {report.period.end}) — trailing average - daily consumption × remaining days, plus standing charge, CCL and VAT for the full period. + daily consumption × remaining days, plus standing charge, CCL, RAB levy, fixed extras and VAT for the full period.
@@ -72,6 +72,8 @@ export default function Estimates() {
{formatMoney(report.totals.usage_cost_pence)}
Usage
{formatMoney(report.totals.standing_cost_pence)}
Standing
{formatMoney(report.totals.ccl_cost_pence)}
CCL
+
{formatMoney(report.totals.rab_levy_cost_pence)}
RAB Levy
+
{formatMoney(report.totals.metering_cost_pence + report.totals.other_charges_cost_pence)}
Fixed extras
{formatMoney(report.totals.vat_pence)}
VAT
diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index d469271..d61e1aa 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -57,6 +57,8 @@ export default function Reports() {
{formatMoney(report.totals.usage_cost_pence)}
Usage
{formatMoney(report.totals.standing_cost_pence)}
Standing
{formatMoney(report.totals.ccl_cost_pence)}
CCL
+
{formatMoney(report.totals.rab_levy_cost_pence)}
RAB Levy
+
{formatMoney(report.totals.metering_cost_pence + report.totals.other_charges_cost_pence)}
Fixed extras
{formatMoney(report.totals.vat_pence)}
VAT
@@ -70,6 +72,7 @@ export default function Reports() { MeterCategoryConsumption UsageStandingCCL + RAB LevyFixed extras VATTotal @@ -90,6 +93,8 @@ export default function Reports() { {formatMoney(m.usage_cost_pence)} {formatMoney(m.standing_cost_pence)} {formatMoney(m.ccl_cost_pence)} + {formatMoney(m.rab_levy_cost_pence)} + {formatMoney(m.metering_cost_pence + m.other_charges_cost_pence)} {formatMoney(m.vat_pence)} {formatMoney(m.total_pence)} @@ -100,6 +105,8 @@ export default function Reports() { {formatMoney(report.totals.usage_cost_pence)} {formatMoney(report.totals.standing_cost_pence)} {formatMoney(report.totals.ccl_cost_pence)} + {formatMoney(report.totals.rab_levy_cost_pence)} + {formatMoney(report.totals.metering_cost_pence + report.totals.other_charges_cost_pence)} {formatMoney(report.totals.vat_pence)} {formatMoney(report.totals.total_pence)} diff --git a/frontend/src/pages/Tariffs.tsx b/frontend/src/pages/Tariffs.tsx index da803d9..f6acf65 100644 --- a/frontend/src/pages/Tariffs.tsx +++ b/frontend/src/pages/Tariffs.tsx @@ -16,6 +16,7 @@ const emptyWindow = (label: string, sort: number): WindowForm => ({ const emptyForm = { id: 0, category_id: 0, name: '', supplier: '', effective_from: new Date().toISOString().slice(0, 10), effective_to: '', standing_charge_pence_per_day: 0, ccl_rate_pence_per_unit: '' as number | '', ccl_exempt: false, + rab_levy_rate_pence_per_unit: '' as number | '', metering_charge_pence_per_month: 0, other_charges_pence_per_month: 0, vat_rate_pct: 20, is_time_of_use: false, windows: [emptyWindow('Standard', 0)], } @@ -55,7 +56,11 @@ export default function Tariffs() { effective_from: full.effective_from.slice(0, 10), effective_to: full.effective_to ? full.effective_to.slice(0, 10) : '', standing_charge_pence_per_day: Number(full.standing_charge_pence_per_day), ccl_rate_pence_per_unit: full.ccl_rate_pence_per_unit != null ? Number(full.ccl_rate_pence_per_unit) : '', - ccl_exempt: full.ccl_exempt, vat_rate_pct: Number(full.vat_rate_pct), is_time_of_use: full.is_time_of_use, + ccl_exempt: full.ccl_exempt, + rab_levy_rate_pence_per_unit: full.rab_levy_rate_pence_per_unit != null ? Number(full.rab_levy_rate_pence_per_unit) : '', + metering_charge_pence_per_month: Number(full.metering_charge_pence_per_month || 0), + other_charges_pence_per_month: Number(full.other_charges_pence_per_month || 0), + vat_rate_pct: Number(full.vat_rate_pct), is_time_of_use: full.is_time_of_use, windows: windows.length ? windows : [emptyWindow('Standard', 0)], }) } catch (err) { @@ -109,7 +114,11 @@ export default function Tariffs() { effective_from: form.effective_from, effective_to: form.effective_to || null, standing_charge_pence_per_day: form.standing_charge_pence_per_day, ccl_rate_pence_per_unit: form.ccl_rate_pence_per_unit === '' ? null : form.ccl_rate_pence_per_unit, - ccl_exempt: form.ccl_exempt, vat_rate_pct: form.vat_rate_pct, is_time_of_use: form.is_time_of_use, + ccl_exempt: form.ccl_exempt, + rab_levy_rate_pence_per_unit: form.rab_levy_rate_pence_per_unit === '' ? null : form.rab_levy_rate_pence_per_unit, + metering_charge_pence_per_month: form.metering_charge_pence_per_month, + other_charges_pence_per_month: form.other_charges_pence_per_month, + vat_rate_pct: form.vat_rate_pct, is_time_of_use: form.is_time_of_use, fallback_split_pct, windows, } if (form.id) { @@ -150,7 +159,7 @@ export default function Tariffs() {
- + {tariffs.map(t => ( @@ -162,6 +171,7 @@ export default function Tariffs() { + ))} @@ -227,6 +237,29 @@ export default function Tariffs() { Climate Change Levy exempt +
+
+ + setForm({ ...form, rab_levy_rate_pence_per_unit: e.target.value === '' ? '' : parseFloat(e.target.value) })} /> +
+
+ + setForm({ ...form, metering_charge_pence_per_month: (parseFloat(e.target.value) || 0) * 100 })} /> +
+
+ + setForm({ ...form, other_charges_pence_per_month: (parseFloat(e.target.value) || 0) * 100 })} /> +
+
+
+ "Other" is a flat catch-all for DUoS availability/excess/reactive charges — these bill against contracted + kVA capacity and kVArh reactive energy from the supplier's own meter, not anything read on-site, so + update it from each bill rather than trying to compute it. +
+
NameCategorySupplierFromToTypeStanding/day
NameCategorySupplierFromToTypeStanding/dayFixed/month
{t.effective_to ? new Date(t.effective_to).toLocaleDateString('en-GB') : open} {t.is_time_of_use ? TOU : 'Standard'} {formatMoney(Number(t.standing_charge_pence_per_day))}{formatMoney(Number(t.metering_charge_pence_per_month) + Number(t.other_charges_pence_per_month))} {t.window_count} window{t.window_count === 1 ? '' : 's'}