energy.tariff-time-of-use
Cost half-hourly consumption against time-of-use rates: Economy 7 windows or Agile-style half-hourly prices.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 24 tests, run in TypeScript, Python and Rust.
What it does
Prices half-hourly smart meter consumption against a time-of-use tariff and returns the bill lines:
- **Economy 7 / Economy 10 / peak and off-peak**: rates for daily windows, `{ name: "night", start: "00:30", end: "07:30", rate: 12000 }`, repeating every day. A window may wrap past midnight (`22:00` to `05:00`), and `from = to` means all day. - **Agile-style dynamic prices**: a rate for each dated interval, `{ name: "agile", start: "2026-03-01T16:00", end: "2026-03-01T16:30", rate: 24999 }`, as a supplier's price list gives them. Prices may be negative.
For example
timeOfUseCost(usage ×5, rates ×2, GBP, half-up)→ lines ×2, watt hours 3,000, total £0.58 Economy 7: 00:00 is still day, 00:30 to 07:00 night, 07:30 day againtimeOfUseCost(usage ×3, rates ×3, GBP, half-up)→ lines ×1, watt hours 2,800, total £0.34 Agile prices summed exactly then rounded once: 34p, where rounding each half hour gives 33ptimeOfUseCost(usage ×1, rates ×3, GBP, half-up)→ lines ×1, watt hours 2,000, total -£0.04 a negative plunge price is a credit
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function timeOfUseCost(usage: readonly HalfHourUsage[], rates: readonly TariffRate[], currency: string, mode: RoundingMode): TimeOfUseCost
| usage | HalfHourUsage[] | one entry per half hour, each start appearing once |
| rates | TariffRate[] | every half hour of usage must fall in exactly one rate |
| currency | string | the currency the rates are in, e.g. GBP |
| mode | RoundingMode | how each line's cost rounds to a whole minor unit |
| returns | TimeOfUseCost |
The types it declares, generated into your project
/** Energy used in one half-hour settlement period. */
export interface HalfHourUsage {
/** local start of the half hour, YYYY-MM-DDTHH:MM, on :00 or :30 */
readonly start: string;
/** energy used in the half hour, in watt-hours (thousandths of a kWh) */
readonly wattHours: number;
}
/** A price for a daily window or a dated interval. */
export interface TariffRate {
/** the bill line it belongs to: day, night, peak, agile */
readonly name: string;
/** HH:MM for a window repeating every day, or YYYY-MM-DDTHH:MM for one interval */
readonly start: string;
/** same form as start, exclusive; a daily window may wrap past midnight, and start = end is all day */
readonly end: string;
/** price per kWh in thousandths of a minor unit: 24.567p is 24567; may be negative */
readonly rate: number;
}
/** One line of the bill: all usage priced under one rate name. */
export interface TimeOfUseLine {
readonly name: string;
readonly wattHours: number;
/** the exact cost of the line rounded once by mode */
readonly cost: Money;
}
/** The itemised cost of the usage. */
export interface TimeOfUseCost {
/** one per rate name, in the order the names first appear in rates */
readonly lines: readonly TimeOfUseLine[];
/** all the usage */
readonly wattHours: number;
/** the sum of the lines */
readonly total: Money;
}
Your code names it in one line, in the file that uses it
import { timeOfUseCost } from "#fune/energy.tariff-time-of-use@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type RoundingMode, roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type HalfHourUsage, type TariffRate, type TimeOfUseCost } from "./energy_tariff_time_of_use_types.ts";
const SLOT = /^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):(00|30)$/;
const CLOCK = /^([01]\d|2[0-3]):(00|30)$/;
// watt-hours x thousandths of a minor unit per kWh is millionths of a minor unit.
const DIVISOR = 1_000_000;
function covers(rate: TariffRate, start: string): boolean {
if (rate.start.length === 5) {
const time = start.slice(11);
if (rate.start === rate.end) return true;
if (rate.start < rate.end) return rate.start <= time && time < rate.end;
return time >= rate.start || time < rate.end;
}
return rate.start <= start && start < rate.end;
}
/**
* Cost half-hourly usage against time-of-use rates. Each half hour must fall
* in exactly one rate; each rate name becomes a line costed exactly and
* rounded once.
*/
export function timeOfUseCost(
usage: readonly HalfHourUsage[],
rates: readonly TariffRate[],
currency: string,
mode: RoundingMode
): TimeOfUseCost {
const names: string[] = [];
for (const r of rates) {
const daily = CLOCK.test(r.start) && CLOCK.test(r.end);
const dated = SLOT.test(r.start) && SLOT.test(r.end);
if (!daily && !dated) {
throw new RangeError(
`rate ${r.name}: start and end must both be HH:MM or both YYYY-MM-DDTHH:MM on the hour or half hour, received ${r.start} and ${r.end}`
);
}
if (dated && r.start >= r.end) {
throw new RangeError(`rate ${r.name}: start must be before end, received ${r.start} and ${r.end}`);
}
if (!Number.isInteger(r.rate)) {
throw new RangeError(`rate ${r.name}: rate must be a whole number of thousandths of a minor unit, received ${r.rate}`);
}
if (!names.includes(r.name)) names.push(r.name);
}
const exact = new Map<string, number>(names.map((n) => [n, 0]));
const energy = new Map<string, number>(names.map((n) => [n, 0]));
const seen = new Set<string>();
let wattHours = 0;
for (const u of usage) {
if (!SLOT.test(u.start)) {
throw new RangeError(`usage start must be YYYY-MM-DDTHH:MM on the hour or half hour, received ${u.start}`);
}
if (!Number.isInteger(u.wattHours) || u.wattHours < 0) {
throw new RangeError(`usage wattHours must be a whole number of 0 or more, received ${u.wattHours} at ${u.start}`);
}
if (seen.has(u.start)) {
throw new RangeError(`usage for the half hour starting ${u.start} appears twice`);
}
seen.add(u.start);
const matches = rates.filter((r) => covers(r, u.start));
if (matches.length === 0) {
throw new RangeError(`no rate covers the half hour starting ${u.start}`);
}
if (matches.length > 1) {
throw new RangeError(`rates ${matches[0].name} and ${matches[1].name} both cover the half hour starting ${u.start}`);
}
const r = matches[0];
const next = exact.get(r.name)! + u.wattHours * r.rate;
if (Math.abs(next) > Number.MAX_SAFE_INTEGER || Math.abs(u.wattHours * r.rate) > Number.MAX_SAFE_INTEGER) {
throw new RangeError("usage cost too large to calculate exactly");
}
exact.set(r.name, next);
energy.set(r.name, energy.get(r.name)! + u.wattHours);
wattHours += u.wattHours;
}
let total = 0;
const lines = names.map((name) => {
const minor = roundDiv(exact.get(name)!, DIVISOR, mode);
total += minor;
return { name, wattHours: energy.get(name)!, cost: money(minor, currency) };
});
return { lines, wattHours, total: money(total, currency) };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add energy.tariff-time-of-use
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./energy.tariff-time-of-use-1.0.1-typescript.fune, or fetch it from a terminal with fune pull energy.tariff-time-of-use@1.0.1:typescript.
The whole function, every language, is one file too: energy.tariff-time-of-use-1.0.1.fune, 32,263 bytes, sha256 1e8b0bc43c7659f220ae8758761991e8ef8c04a3e391ef49b21306675472d480. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before energy.tariff-time-of-use
after — your function gets the result and the arguments, and returns the final result.
// fune: after energy.tariff-time-of-use
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace math.round-div in energy.tariff-time-of-use
// fune: replace money.amount in energy.tariff-time-of-use
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show energy.tariff-time-of-use --steps.
// fune: step energy.tariff-time-of-use after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| Economy 7: 00:00 is still day, 00:30 to 07:00 night, 07:30 day again | usage ×5, rates ×2, GBP, half-up | → | lines ×2, watt hours 3,000, total £0.58 |
| Agile prices summed exactly then rounded once: 34p, where rounding each half hour gives 33p | usage ×3, rates ×3, GBP, half-up | → | lines ×1, watt hours 2,800, total £0.34 |
| a negative plunge price is a credit | usage ×1, rates ×3, GBP, half-up | → | lines ×1, watt hours 2,000, total -£0.04 |
| a window that wraps midnight, 22:00 to 05:00 | usage ×3, rates ×2, GBP, half-up | → | lines ×2, watt hours 3,000, total £0.53 |
| start equal to end is a flat rate all day | usage ×2, rates ×1, GBP, half-up | → | lines ×1, watt hours 2,000, total £0.49 |
| half a penny rounds up under half-up | usage ×1, rates ×1, GBP, half-up | → | lines ×1, watt hours 500, total £0.01 |
| half a penny rounds to the even 0 under half-even | usage ×1, rates ×1, GBP, half-even | → | lines ×1, watt hours 500, total £0.00 |
| a rate name with no usage still gets a zero line; no usage at all is zero | , rates ×2, GBP, half-up | → | lines ×2, watt hours 0, total £0.00 |
| a dated peak between two daily windows: the peak covers 17:00-19:00 on that day only | usage ×2, rates ×3, GBP, half-up | → | lines ×3, watt hours 2,000, total £0.60 |
| daily and dated rates together without overlap | usage ×2, rates ×3, GBP, half-up | → | error: rates peak and late both cover the half hour starting 2026-06-01T18:00 |
Show the other 14 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| line costs sum to the total even when the exact total would round differently | usage ×2, rates ×2, GBP, half-up | → | lines ×2, watt hours 1,000, total £0.02 |
| a half hour no rate covers is an error, not free energy | usage ×1, rates ×1, GBP, half-up | → | error: no rate covers the half hour starting 2026-01-15T08:00 |
| overlapping windows are an error | usage ×1, rates ×2, GBP, half-up | → | error: rates night and day both cover the half hour starting 2026-01-15T07:00 |
| the same half hour twice is an error | usage ×2, rates ×2, GBP, half-up | → | error: usage for the half hour starting 2026-01-15T07:00 appears twice |
| a usage start off the half hour is refused | usage ×1, rates ×2, GBP, half-up | → | error: usage start must be YYYY-MM-DDTHH:MM on the hour or half hour |
| a rate window off the half hour is refused | , rates ×1, GBP, half-up | → | error: rate night: start and end must both be HH:MM or both YYYY-MM-DDTHH:MM |
| a dated rate that ends before it starts is refused | , rates ×1, GBP, half-up | → | error: rate agile: start must be before end |
| negative usage is refused | usage ×1, rates ×2, GBP, half-up | → | error: usage wattHours must be a whole number of 0 or more |
| a fractional rate is refused | , rates ×1, GBP, half-up | → | error: rate flat: rate must be a whole number of thousandths of a minor unit |
| a usage start with a trailing newline is refused | usage ×1, rates ×2, GBP, half-up | → | error: usage start must be YYYY-MM-DDTHH:MM on the hour or half hour |
| a usage start in Arabic-Indic digits is refused | usage ×1, rates ×2, GBP, half-up | → | error: usage start must be YYYY-MM-DDTHH:MM on the hour or half hour |
| a daily rate end with a trailing newline is refused | , rates ×1, GBP, half-up | → | error: rate night: start and end must both be HH:MM or both YYYY-MM-DDTHH:MM |
| a daily rate start in Arabic-Indic digits is refused | , rates ×1, GBP, half-up | → | error: rate night: start and end must both be HH:MM or both YYYY-MM-DDTHH:MM |
| a dated rate start with a trailing newline is refused | , rates ×1, GBP, half-up | → | error: rate agile: start and end must both be HH:MM or both YYYY-MM-DDTHH:MM |
More from the author
Both kinds can be mixed; every half hour of usage must fall in **exactly one** rate, and an overlap or a gap is an error naming the half hour, so a price list with a missing slot cannot silently undercharge.
## Units and rounding
- Usage is in **watt-hours** (thousandths of a kWh), the resolution smart meter half-hourly data comes in. - Rates are in **thousandths of a minor unit per kWh**: 24.567p/kWh is `24567`. Dynamic prices are published to more places than a penny holds (15.4035p); thousandths keep them to within 0.0005p, which is at most a penny on 2,000 kWh. - Each line is costed **exactly** (sum of watt-hours x rate over its half hours) and rounded **once** by `mode`; the total is the sum of the rounded lines, so it always matches the lines printed. Rounding each half hour instead drifts, by up to half a penny per half hour, 17,520 times a year. - Lines are grouped by rate `name`, in the order names first appear in `rates`; a name with no usage still gets a zero line, so a bill always has the same lines.
## Clocks
Times are compared as written: the usage and the rates must be in the same clock. Smart meter data is usually in UTC, and many Economy 7 meters switch on GMT all year, so in summer their night window is an hour later in local time. Convert one side before calling. Only the form of a date is checked (`YYYY-MM-DD`), not that it exists.
## Bounds
A line's exact cost (watt-hours x rate) must stay within 2^53 millionths of a minor unit, about 300 GWh at 30p/kWh.
1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in usage and rate start and end times; adds tests.
Files
| Path | Bytes |
|---|---|
| README.md | 2,245 |
| impl/python.py | 3,885 |
| impl/rust.rs | 6,204 |
| impl/typescript.ts | 3,621 |
| vectors.json | 10,153 |