Functional Weave
Code in Python

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.

def time_of_use_cost(usage: Sequence[HalfHourUsage], rates: Sequence[TariffRate], currency: str, mode: RoundingMode) -> 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

@dataclass(frozen=True)
class HalfHourUsage:
    """Energy used in one half-hour settlement period."""

    #: local start of the half hour, YYYY-MM-DDTHH:MM, on :00 or :30
    start: str
    #: energy used in the half hour, in watt-hours (thousandths of a kWh)
    watt_hours: int

@dataclass(frozen=True)
class TariffRate:
    """A price for a daily window or a dated interval."""

    #: the bill line it belongs to: day, night, peak, agile
    name: str
    #: HH:MM for a window repeating every day, or YYYY-MM-DDTHH:MM for one interval
    start: str
    #: same form as start, exclusive; a daily window may wrap past midnight, and start = end is all day
    end: str
    #: price per kWh in thousandths of a minor unit: 24.567p is 24567; may be negative
    rate: int

@dataclass(frozen=True)
class TimeOfUseLine:
    """One line of the bill: all usage priced under one rate name."""

    name: str
    watt_hours: int
    #: the exact cost of the line rounded once by mode
    cost: Money

@dataclass(frozen=True)
class TimeOfUseCost:
    """The itemised cost of the usage."""

    #: one per rate name, in the order the names first appear in rates
    lines: List[TimeOfUseLine]
    #: all the usage
    watt_hours: int
    #: the sum of the lines
    total: Money

Your code names it in one line, in the file that uses it

from fune.energy.tariff_time_of_use import time_of_use_cost  # energy.tariff-time-of-use@^1
impl/python.py · 89 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.

import re
from typing import Dict, List, Sequence

from .energy_tariff_time_of_use_types import HalfHourUsage, TariffRate, TimeOfUseCost, TimeOfUseLine
from .math_round_div import RoundingMode, round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import money  ← from money.amount ^1.0.0 · built alongside by fune

SLOT = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):(00|30)")
CLOCK = re.compile(r"([01][0-9]|2[0-3]):(00|30)")
# watt-hours x thousandths of a minor unit per kWh is millionths of a minor unit.
DIVISOR = 1_000_000
MAX_SAFE = 9007199254740991


def _is_int(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def _covers(rate: TariffRate, start: str) -> bool:
    if len(rate.start) == 5:
        time = start[11:]
        if rate.start == rate.end:
            return True
        if rate.start < rate.end:
            return rate.start <= time < rate.end
        return time >= rate.start or time < rate.end
    return rate.start <= start < rate.end


def time_of_use_cost(
    usage: Sequence[HalfHourUsage], rates: Sequence[TariffRate], currency: str, mode: RoundingMode
) -> TimeOfUseCost:
    """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.
    """
    names: List[str] = []
    for r in rates:
        daily = CLOCK.fullmatch(r.start) is not None and CLOCK.fullmatch(r.end) is not None
        dated = SLOT.fullmatch(r.start) is not None and SLOT.fullmatch(r.end) is not None
        if not daily and not dated:
            raise ValueError(
                "rate %s: start and end must both be HH:MM or both YYYY-MM-DDTHH:MM on the hour or half hour, "
                "received %s and %s" % (r.name, r.start, r.end)
            )
        if dated and r.start >= r.end:
            raise ValueError("rate %s: start must be before end, received %s and %s" % (r.name, r.start, r.end))
        if not _is_int(r.rate):
            raise ValueError(
                "rate %s: rate must be a whole number of thousandths of a minor unit, received %s" % (r.name, r.rate)
            )
        if r.name not in names:
            names.append(r.name)
    exact: Dict[str, int] = {n: 0 for n in names}
    energy: Dict[str, int] = {n: 0 for n in names}
    seen = set()
    watt_hours = 0
    for u in usage:
        if not isinstance(u.start, str) or not SLOT.fullmatch(u.start):
            raise ValueError("usage start must be YYYY-MM-DDTHH:MM on the hour or half hour, received %s" % (u.start,))
        if not _is_int(u.watt_hours) or u.watt_hours < 0:
            raise ValueError(
                "usage wattHours must be a whole number of 0 or more, received %s at %s" % (u.watt_hours, u.start)
            )
        if u.start in seen:
            raise ValueError("usage for the half hour starting %s appears twice" % (u.start,))
        seen.add(u.start)
        matches = [r for r in rates if _covers(r, u.start)]
        if not matches:
            raise ValueError("no rate covers the half hour starting %s" % (u.start,))
        if len(matches) > 1:
            raise ValueError(
                "rates %s and %s both cover the half hour starting %s" % (matches[0].name, matches[1].name, u.start)
            )
        r = matches[0]
        part = u.watt_hours * r.rate
        nxt = exact[r.name] + part
        if abs(nxt) > MAX_SAFE or abs(part) > MAX_SAFE:
            raise ValueError("usage cost too large to calculate exactly")
        exact[r.name] = nxt
        energy[r.name] += u.watt_hours
        watt_hours += u.watt_hours
    lines = []
    total = 0
    for name in names:
        minor = round_div(exact[name], DIVISOR, mode)
        total += minor
        lines.append(TimeOfUseLine(name=name, watt_hours=energy[name], cost=money(minor, currency)))
    return TimeOfUseCost(lines=lines, watt_hours=watt_hours, total=money(total, currency))

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python 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
Download for Python energy.tariff-time-of-use-1.0.1-python.fune · 22,089 bytes sha256 1a45897b55e63a1215b026ecc13ab3bf8750839d540f995175c5eece2dbbb813

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./energy.tariff-time-of-use-1.0.1-python.fune, or fetch it from a terminal with fune pull energy.tariff-time-of-use@1.0.1:python.

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