Add Nuclear RAB levy, metering charge and DUoS catch-all to tariff cost model

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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-28 13:07:20 +00:00
parent f1ce0f06d8
commit 3b261a4d8d
10 changed files with 158 additions and 15 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
]
)