Functional Weave
Code in Rust

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 again
  • time_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 33p
  • time_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
usageHalfHourUsage[]one entry per half hour, each start appearing once
ratesTariffRate[]every half hour of usage must fall in exactly one rate
currencystringthe currency the rates are in, e.g. GBP
modeRoundingModehow each line's cost rounds to a whole minor unit
returnsTimeOfUseCost

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(…)
impl/rust.rs · 174 lines · open · raw

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
Download for Rust energy.tariff-time-of-use-1.0.1-rust.fune · 24,505 bytes sha256 43d16a711abc305cf47105776c7ea0d169a295a46163a45acf42cd2ab1817e98

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.

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

PathBytes
README.md2,245
impl/python.py3,885
impl/rust.rs6,204
impl/typescript.ts3,621
vectors.json10,153