Functional Weave
Code in Rust

fleet.ev-charging-cost

Cost of an electric vehicle charge from the energy added, charger efficiency and power, and time-of-use tariff windows.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 24 tests, run in TypeScript, Python and Rust.

What it does

What one electric vehicle charge costs on a time-of-use tariff (Economy 7, an EV tariff with a cheap overnight window, or a flat public rate), from the energy that went into the battery, the charger's efficiency and power, and the time charging started.

## How the energy is priced

For example

  • ev_charging_cost(40,000, 90%, 7,000, 22:00, tariff ×1, GBP, half-up) → energy drawn wh 44,444, minutes 381, cost £10.89 40 kWh at 90% efficiency draws 44.44 kWh: at 24.5p that is 1088.9p
  • ev_charging_cost(40,000, 100%, 7,000, 22:00, tariff ×1, GBP, half-up) → energy drawn wh 40,000, minutes 343, cost £9.80 the same charge with no losses costs 980p
  • ev_charging_cost(35,000, 100%, 7,000, 23:30, tariff ×2, GBP, half-up) → energy drawn wh 35,000, minutes 300, cost £3.82 from 23:30 the first hour is at the day rate, the rest off-peak: 171.5p + 210p (the start-time rate alone would say 857.5p)

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 ev_charging_cost(energy_added_wh: i64, efficiency_basis_points: i64, charger_power_watts: i64, start_time: &str, tariff: &[TariffWindow], currency: &str, mode: &str) -> EvChargingCost
energy_added_whintenergy that went into the battery, in watt hours: 40000 = 40 kWh; at most 1,000,000
efficiency_basis_pointsintshare of the energy drawn that reaches the battery, 1 to 10000: 9000 = 90%
charger_power_wattsintpower drawn from the supply while charging, 1 to 1,000,000: 7000 = 7 kW
start_timestringlocal time charging starts, HH:MM
tariffTariffWindow[]windows in ascending order of start; each runs until the next, and the last wraps round midnight to the first
currencystringISO 4217 code of the prices
modeRoundingModehow the exact cost is rounded to a minor unit
returnsEvChargingCost

The types it declares, generated into your project

/// One price of a time-of-use tariff, from its start time until the next window starts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TariffWindow {
    /// HH:MM, 24-hour
    pub start: String,
    /// price per kWh in tenths of a minor unit: 245 = 24.5p
    pub price_per_kwh_tenths: i64,
}

/// What the charge drew, how long it took and what it cost.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EvChargingCost {
    /// energy taken from the supply, rounded half-up to a watt hour
    pub energy_drawn_wh: i64,
    /// charging time, rounded up to a whole minute
    pub minutes: i64,
    /// from the exact energy in each window, rounded once
    pub cost: Money,
}

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

fune!(fleet.ev-charging-cost@^1);  // then call ev_charging_cost(…)
impl/rust.rs · 133 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 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_big::round_div_big;  ← from math.round-div-big ^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

const DAY: i64 = 1440;

fn minute_of_day(name: &str, text: &str) -> i64 {
    let b = text.as_bytes();
    let ok = b.len() == 5
        && b[2] == b':'
        && b.iter().enumerate().all(|(i, c)| i == 2 || c.is_ascii_digit())
        && ((b[0] - b'0') * 10 + (b[1] - b'0')) < 24
        && b[3] <= b'5';
    if !ok {
        panic!("{} must be HH:MM, 00:00 to 23:59, received \"{}\"", name, text);
    }
    (((b[0] - b'0') as i64) * 10 + (b[1] - b'0') as i64) * 60 + ((b[3] - b'0') as i64) * 10 + (b[4] - b'0') as i64
}

fn whole(name: &str, value: i64, lo: i64, hi: i64, rule: &str) {
    if value < lo || value > hi {
        panic!("{} must be {}, received {}", name, rule, value);
    }
}

/// The cost of one charge on a time-of-use tariff.
///
/// The energy drawn is more than the energy added (the charger and battery
/// lose some), and it flows at the charger's power from the start time, so it
/// falls into the tariff's windows in order. Everything is counted in exact
/// units of 1/(60 x efficiency) Wh, where a minute of charging is a whole
/// number, so the only rounding is the final one.
///
/// # Panics
/// Panics on an out-of-range number, a malformed time, an empty or unsorted
/// tariff, a negative price, a bad currency or an unknown mode.
pub fn ev_charging_cost(
    energy_added_wh: i64,
    efficiency_basis_points: i64,
    charger_power_watts: i64,
    start_time: &str,
    tariff: &[TariffWindow],
    currency: &str,
    mode: &str,
) -> EvChargingCost {
    whole("energyAddedWh", energy_added_wh, 0, 1000000, "a whole number from 0 to 1000000");
    whole("efficiencyBasisPoints", efficiency_basis_points, 1, 10000, "a whole number from 1 to 10000");
    whole("chargerPowerWatts", charger_power_watts, 1, 1000000, "a whole number from 1 to 1000000");
    let mut t = minute_of_day("startTime", start_time);
    if tariff.is_empty() {
        panic!("tariff must have at least one window");
    }
    let mut starts: Vec<i64> = Vec::new();
    for window in tariff {
        let start = minute_of_day("tariff start", &window.start);
        if let Some(last) = starts.last() {
            if start <= *last {
                panic!("tariff windows must be in ascending order of start time");
            }
        }
        whole("pricePerKwhTenths", window.price_per_kwh_tenths, 0, 9007199254740991, "a whole number, 0 or more");
        starts.push(start);
    }
    money(0, currency);

    let eff = efficiency_basis_points as i128;
    let per_minute = charger_power_watts as i128 * eff;
    let mut remaining = energy_added_wh as i128 * 600000;
    let total = remaining;
    let mut cost_numerator: i128 = 0;
    while remaining > 0 {
        let mut index = starts.len() - 1;
        for (i, s) in starts.iter().enumerate() {
            if *s <= t {
                index = i;
            }
        }
        let next = if index + 1 < starts.len() { starts[index + 1] } else { starts[0] };
        let length = if next > t { next - t } else { next + DAY - t };
        let take = remaining.min(length as i128 * per_minute);
        cost_numerator = cost_numerator
            .checked_add(take.checked_mul(tariff[index].price_per_kwh_tenths as i128).expect("the charging cost is too large"))
            .expect("the charging cost is too large");
        remaining -= take;
        t = (t + length) % DAY;
    }

    let unit = 60 * eff;
    let cost = round_div_big(&cost_numerator.to_string(), &(unit * 10000).to_string(), mode);
    EvChargingCost {
        energy_drawn_wh: round_div_big(&total.to_string(), &unit.to_string(), "half-up").parse().unwrap(),
        minutes: round_div_big(&total.to_string(), &per_minute.to_string(), "up").parse().unwrap(),
        cost: money(cost.parse().expect("the charging cost is too large"), currency),
    }
}

pub fn ev_charging_cost_to_value(c: &EvChargingCost) -> Value {
    Value::obj(vec![
        ("energyDrawnWh", Value::Int(c.energy_drawn_wh)),
        ("minutes", Value::Int(c.minutes)),
        ("cost", money_to_value(&c.cost)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    for (i, name) in [(0usize, "energyAddedWh"), (1, "efficiencyBasisPoints"), (2, "chargerPowerWatts")] {
        if let Value::Float(f) = &args[i] {
            panic!("{} must be a whole number, received {}", name, f);
        }
    }
    let tariff: Vec<TariffWindow> = args[4]
        .as_arr()
        .iter()
        .map(|w| {
            if let Value::Float(f) = w.get("pricePerKwhTenths") {
                panic!("pricePerKwhTenths must be a whole number, 0 or more, received {}", f);
            }
            TariffWindow {
                start: w.get("start").as_str().to_string(),
                price_per_kwh_tenths: w.get("pricePerKwhTenths").as_i64(),
            }
        })
        .collect();
    ev_charging_cost_to_value(&ev_charging_cost(
        args[0].as_i64(),
        args[1].as_i64(),
        args[2].as_i64(),
        args[3].as_str(),
        &tariff,
        args[5].as_str(),
        args[6].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 3 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 fleet.ev-charging-cost
Download for Rust fleet.ev-charging-cost-1.0.0-rust.fune · 18,608 bytes sha256 48c8b958f43f3eb5fbe7809e40ecd06e29768e8ab29bcac37c7d30f10dfed59f

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./fleet.ev-charging-cost-1.0.0-rust.fune, or fetch it from a terminal with fune pull fleet.ev-charging-cost@1.0.0:rust.

The whole function, every language, is one file too: fleet.ev-charging-cost-1.0.0.fune, 25,531 bytes, sha256 9fc287a33d2b3f706f8c094b4b8abdd0ffd4c82a126327139af47506725c396c. 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 fleet.ev-charging-cost

after — your function gets the result and the arguments, and returns the final result.

// fune: after fleet.ev-charging-cost

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 fleet.ev-charging-cost
// fune: replace math.round-div-big in fleet.ev-charging-cost
// fune: replace money.amount in fleet.ev-charging-cost

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 fleet.ev-charging-cost --steps.

// fune: step fleet.ev-charging-cost 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
40 kWh at 90% efficiency draws 44.44 kWh: at 24.5p that is 1088.9p 40,000, 90%, 7,000, 22:00, tariff ×1, GBP, half-up → energy drawn wh 44,444, minutes 381, cost £10.89
the same charge with no losses costs 980p 40,000, 100%, 7,000, 22:00, tariff ×1, GBP, half-up → energy drawn wh 40,000, minutes 343, cost £9.80
from 23:30 the first hour is at the day rate, the rest off-peak: 171.5p + 210p (the start-time rate alone would say 857.5p) 35,000, 100%, 7,000, 23:30, tariff ×2, GBP, half-up → energy drawn wh 35,000, minutes 300, cost £3.82
the same 381.5p rounded down 35,000, 100%, 7,000, 23:30, tariff ×2, GBP, down → energy drawn wh 35,000, minutes 300, cost £3.81
the same 381.5p rounded half-even goes to the even 382 35,000, 100%, 7,000, 23:30, tariff ×2, GBP, half-even → energy drawn wh 35,000, minutes 300, cost £3.82
entirely inside the off-peak window 20,000, 100%, 7,000, 01:00, tariff ×2, GBP, half-up → energy drawn wh 20,000, minutes 172, cost £1.50
running past the end of off-peak: 18.5 kWh at 7.5p then 6.5 kWh at 24.5p 25,000, 100%, 7,400, 03:00, tariff ×2, GBP, half-up → energy drawn wh 25,000, minutes 203, cost £2.98
a slow charge over four days and four hours crosses every window many times 10,000, 100%, 100, 00:00, tariff ×2, GBP, half-up → energy drawn wh 10,000, minutes 6,000, cost £1.48
before the first window of the day the last one still applies, wrapping past midnight 7,000, 100%, 7,000, 06:00, tariff ×2, GBP, half-up → energy drawn wh 7,000, minutes 60, cost £0.70
starting exactly on a window's start takes that window's price 7,000, 100%, 7,000, 07:00, tariff ×2, GBP, half-up → energy drawn wh 7,000, minutes 60, cost £2.10
Show the other 14 tests
CaseArgumentsExpected
nothing added costs nothing and takes no time 0, 90%, 7,000, 12:00, tariff ×1, GBP, half-up → energy drawn wh 0, minutes 0, cost £0.00
a rapid charger in euro cents: 50 kWh at 94% and 150 kW 50,000, 94%, 150,000, 14:10, tariff ×1, EUR, half-up → energy drawn wh 53,191, minutes 22, cost €31.38
an empty tariff is an error 1,000, 90%, 7,000, 12:00, , GBP, half-up → error: tariff must have at least one window
windows out of order are an error 1,000, 90%, 7,000, 12:00, tariff ×2, GBP, half-up → error: tariff windows must be in ascending order of start time
two windows starting at the same time are an error 1,000, 90%, 7,000, 12:00, tariff ×2, GBP, half-up → error: tariff windows must be in ascending order of start time
24:00 is not a start time 1,000, 90%, 7,000, 24:00, tariff ×1, GBP, half-up → error: startTime must be HH:MM, 00:00 to 23:59
a tariff window time without a leading zero is an error 1,000, 90%, 7,000, 12:00, tariff ×1, GBP, half-up → error: tariff start must be HH:MM, 00:00 to 23:59
zero efficiency is an error 1,000, 0%, 7,000, 12:00, tariff ×1, GBP, half-up → error: efficiencyBasisPoints must be a whole number from 1 to 10000
more than 100% efficiency is an error 1,000, 100.01%, 7,000, 12:00, tariff ×1, GBP, half-up → error: efficiencyBasisPoints must be a whole number from 1 to 10000
zero charger power is an error 1,000, 90%, 0, 12:00, tariff ×1, GBP, half-up → error: chargerPowerWatts must be a whole number from 1 to 1000000
negative energy is an error -1, 90%, 7,000, 12:00, tariff ×1, GBP, half-up → error: energyAddedWh must be a whole number from 0 to 1000000
fractional watt hours are an error 1,000.5, 90%, 7,000, 12:00, tariff ×1, GBP, half-up → error: energyAddedWh must be a whole number
a negative price is an error 1,000, 90%, 7,000, 12:00, tariff ×1, GBP, half-up → error: pricePerKwhTenths must be a whole number, 0 or more
a bad currency is an error 1,000, 90%, 7,000, 12:00, tariff ×1, pounds, half-up → error: is not an uppercase ISO 4217 currency code

More from the author

1. **Energy drawn** is more than energy added: the charger and battery lose some. `efficiencyBasisPoints` is the share that arrives, so 40 kWh added at 90% draws 44.44 kWh from the supply, and that is what the meter bills. 2. **When it is drawn**: at `chargerPowerWatts` from `startTime`, without a break, so the energy falls into the tariff windows in order. A charge starting at 23:30 on a tariff that is cheap from 00:30 pays the day rate for its first hour. Pricing the whole charge at the rate in force when it started is the usual mistake. 3. **The cost** is the energy in each window times that window's price, summed exactly and rounded once, with the mode you choose.

The charger is assumed to draw its full rated power throughout. Real charging slows as the battery fills (above about 80% on a rapid charger), so for a rapid charge to 100% this is a lower bound on the time and, where the price changes during the charge, an estimate of the split.

## The tariff

A list of windows, each a start time (`HH:MM`) and a price per kWh in tenths of a minor unit (24.5p is `245`), in ascending order of start. A window runs until the next one starts, and the last one runs past midnight to the first, so `[{00:30, 75}, {05:30, 245}]` is 7.5p from 00:30 to 05:30 and 24.5p for the other 19 hours. One window is a flat rate. Standing charges are per day, not per charge, so they are not included.

## Exactness

A minute of charging moves `chargerPowerWatts / 60` Wh into the meter's count, and energy added becomes drawn energy by dividing by the efficiency. Counting in units of 1/(60 x efficiency) Wh makes both whole numbers, so every window's share is exact and the only rounding is the final one: cost to the minor unit (`mode`), energy drawn to the watt hour (half-up) and time up to the next whole minute. A charge longer than a day goes round the tariff as many times as it needs to.

## Limits and errors

Energy added is 0 to 1,000,000 Wh (1 MWh), efficiency 1 to 10000 basis points, charger power 1 W to 1 MW. Times must be `HH:MM` from 00:00 to 23:59. An empty tariff, windows out of order or starting at the same time, a negative price, a bad currency code or rounding mode are errors.

This capability is self-contained: no `energy.*` capability existed to build on when it was written.

Files

PathBytes
README.md2,636
impl/python.py3,240
impl/rust.rs5,164
impl/typescript.ts3,408
vectors.json6,363