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.9pev_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 980pev_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_wh | int | energy that went into the battery, in watt hours: 40000 = 40 kWh; at most 1,000,000 |
| efficiency_basis_points | int | share of the energy drawn that reaches the battery, 1 to 10000: 9000 = 90% |
| charger_power_watts | int | power drawn from the supply while charging, 1 to 1,000,000: 7000 = 7 kW |
| start_time | string | local time charging starts, HH:MM |
| tariff | TariffWindow[] | windows in ascending order of start; each runs until the next, and the last wraps round midnight to the first |
| currency | string | ISO 4217 code of the prices |
| mode | RoundingMode | how the exact cost is rounded to a minor unit |
| returns | EvChargingCost |
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(…)
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 2,636 |
| impl/python.py | 3,240 |
| impl/rust.rs | 5,164 |
| impl/typescript.ts | 3,408 |
| vectors.json | 6,363 |