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
time_of_use_cost(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 againtime_of_use_cost(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 33ptime_of_use_cost(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.
pub fn time_of_use_cost(usage: &[HalfHourUsage], rates: &[TariffRate], currency: &str, mode: &str) -> 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HalfHourUsage {
/// local start of the half hour, YYYY-MM-DDTHH:MM, on :00 or :30
pub start: String,
/// energy used in the half hour, in watt-hours (thousandths of a kWh)
pub watt_hours: i64,
}
/// A price for a daily window or a dated interval.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TariffRate {
/// the bill line it belongs to: day, night, peak, agile
pub name: String,
/// HH:MM for a window repeating every day, or YYYY-MM-DDTHH:MM for one interval
pub start: String,
/// same form as start, exclusive; a daily window may wrap past midnight, and start = end is all day
pub end: String,
/// price per kWh in thousandths of a minor unit: 24.567p is 24567; may be negative
pub rate: i64,
}
/// One line of the bill: all usage priced under one rate name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeOfUseLine {
pub name: String,
pub watt_hours: i64,
/// the exact cost of the line rounded once by mode
pub cost: Money,
}
/// The itemised cost of the usage.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeOfUseCost {
/// one per rate name, in the order the names first appear in rates
pub lines: Vec<TimeOfUseLine>,
/// all the usage
pub watt_hours: i64,
/// the sum of the lines
pub total: Money,
}
Your code names it in one line, in the file that uses it
fune!(energy.tariff-time-of-use@^1); // then call time_of_use_cost(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use std::collections::HashSet;
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_to_value}; ← from money.amount ^1.0.0 · built alongside by fune
// watt-hours x thousandths of a minor unit per kWh is millionths of a minor unit.
const DIVISOR: i64 = 1_000_000;
const MAX_SAFE: i128 = 9_007_199_254_740_991;
fn is_clock(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 5
&& b[2] == b':'
&& b[0].is_ascii_digit()
&& b[1].is_ascii_digit()
&& ((b[0] <= b'1') || (b[0] == b'2' && b[1] <= b'3'))
&& ((b[3] == b'0' && b[4] == b'0') || (b[3] == b'3' && b[4] == b'0'))
}
fn is_slot(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 16
&& b[4] == b'-'
&& b[7] == b'-'
&& b[10] == b'T'
&& [0, 1, 2, 3, 5, 6, 8, 9].iter().all(|&i| b[i].is_ascii_digit())
&& is_clock(&s[11..])
}
fn covers(rate: &TariffRate, start: &str) -> bool {
if rate.start.len() == 5 {
let time = &start[11..];
if rate.start == rate.end {
return true;
}
if rate.start < rate.end {
return rate.start.as_str() <= time && time < rate.end.as_str();
}
return time >= rate.start.as_str() || time < rate.end.as_str();
}
rate.start.as_str() <= start && start < rate.end.as_str()
}
/// 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.
///
/// # Panics
/// Panics on a malformed rate or usage entry, a duplicated half hour, a half
/// hour covered by no rate or by two, or a cost too large to be exact.
pub fn time_of_use_cost(usage: &[HalfHourUsage], rates: &[TariffRate], currency: &str, mode: &str) -> TimeOfUseCost {
let mut names: Vec<String> = Vec::new();
for r in rates {
let daily = is_clock(&r.start) && is_clock(&r.end);
let dated = is_slot(&r.start) && is_slot(&r.end);
if !daily && !dated {
panic!(
"rate {}: start and end must both be HH:MM or both YYYY-MM-DDTHH:MM on the hour or half hour, received {} and {}",
r.name, r.start, r.end
);
}
if dated && r.start >= r.end {
panic!("rate {}: start must be before end, received {} and {}", r.name, r.start, r.end);
}
if !names.contains(&r.name) {
names.push(r.name.clone());
}
}
let mut exact: Vec<i128> = vec![0; names.len()];
let mut energy: Vec<i64> = vec![0; names.len()];
let mut seen: HashSet<&str> = HashSet::new();
let mut watt_hours = 0;
for u in usage {
if !is_slot(&u.start) {
panic!("usage start must be YYYY-MM-DDTHH:MM on the hour or half hour, received {}", u.start);
}
if u.watt_hours < 0 {
panic!("usage wattHours must be a whole number of 0 or more, received {} at {}", u.watt_hours, u.start);
}
if !seen.insert(u.start.as_str()) {
panic!("usage for the half hour starting {} appears twice", u.start);
}
let matches: Vec<&TariffRate> = rates.iter().filter(|r| covers(r, &u.start)).collect();
if matches.is_empty() {
panic!("no rate covers the half hour starting {}", u.start);
}
if matches.len() > 1 {
panic!(
"rates {} and {} both cover the half hour starting {}",
matches[0].name, matches[1].name, u.start
);
}
let r = matches[0];
let i = names.iter().position(|n| *n == r.name).unwrap();
let part = u.watt_hours as i128 * r.rate as i128;
let next = exact[i] + part;
if next.abs() > MAX_SAFE || part.abs() > MAX_SAFE {
panic!("usage cost too large to calculate exactly");
}
exact[i] = next;
energy[i] += u.watt_hours;
watt_hours += u.watt_hours;
}
let mut total = 0;
let mut lines = Vec::new();
for (i, name) in names.iter().enumerate() {
let minor = round_div(exact[i] as i64, DIVISOR, mode);
total += minor;
lines.push(TimeOfUseLine {
name: name.clone(),
watt_hours: energy[i],
cost: money(minor, currency),
});
}
TimeOfUseCost {
lines,
watt_hours,
total: money(total, currency),
}
}
fn whole(v: &Value, message: &str) -> i64 {
if let Value::Float(f) = v {
panic!("{}, received {}", message, f);
}
v.as_i64()
}
pub fn half_hour_usage_from_value(v: &Value) -> HalfHourUsage {
HalfHourUsage {
start: v.get("start").as_str().to_string(),
watt_hours: whole(v.get("wattHours"), "usage wattHours must be a whole number of 0 or more"),
}
}
pub fn tariff_rate_from_value(v: &Value) -> TariffRate {
let name = v.get("name").as_str().to_string();
let message = format!("rate {}: rate must be a whole number of thousandths of a minor unit", name);
TariffRate {
rate: whole(v.get("rate"), &message),
name,
start: v.get("start").as_str().to_string(),
end: v.get("end").as_str().to_string(),
}
}
pub fn time_of_use_cost_to_value(c: &TimeOfUseCost) -> Value {
Value::obj(vec![
(
"lines",
Value::Arr(
c.lines
.iter()
.map(|l| {
Value::obj(vec![
("name", Value::str(&l.name)),
("wattHours", Value::Int(l.watt_hours)),
("cost", money_to_value(&l.cost)),
])
})
.collect(),
),
),
("wattHours", Value::Int(c.watt_hours)),
("total", money_to_value(&c.total)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let usage: Vec<HalfHourUsage> = args[0].as_arr().iter().map(half_hour_usage_from_value).collect();
let rates: Vec<TariffRate> = args[1].as_arr().iter().map(tariff_rate_from_value).collect();
time_of_use_cost_to_value(&time_of_use_cost(&usage, &rates, args[2].as_str(), args[3].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./energy.tariff-time-of-use-1.0.1-rust.fune, or fetch it from a terminal with fune pull energy.tariff-time-of-use@1.0.1:rust.
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 |