import re from typing import Sequence from .fleet_ev_charging_cost_types import EvChargingCost, TariffWindow from .math_round_div import RoundingMode from .math_round_div_big import round_div_big from .money_amount import money CLOCK = re.compile(r"^([01][0-9]|2[0-3]):[0-5][0-9]$") DAY = 1440 MAX_SAFE = 9007199254740991 def _minute_of_day(name: str, text: str) -> int: if not isinstance(text, str) or not CLOCK.fullmatch(text): raise ValueError('%s must be HH:MM, 00:00 to 23:59, received "%s"' % (name, text)) return int(text[0:2]) * 60 + int(text[3:5]) def _whole(name: str, value: int, lo: int, hi: int, rule: str) -> None: if isinstance(value, bool) or not isinstance(value, int) or value < lo or value > hi: raise ValueError("%s must be %s, received %r" % (name, rule, value)) def ev_charging_cost( energy_added_wh: int, efficiency_basis_points: int, charger_power_watts: int, start_time: str, tariff: Sequence[TariffWindow], currency: str, mode: RoundingMode, ) -> EvChargingCost: """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. """ _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") t = _minute_of_day("startTime", start_time) if len(tariff) == 0: raise ValueError("tariff must have at least one window") starts = [] for window in tariff: start = _minute_of_day("tariff start", window.start) if starts and start <= starts[-1]: raise ValueError("tariff windows must be in ascending order of start time") _whole("pricePerKwhTenths", window.price_per_kwh_tenths, 0, MAX_SAFE, "a whole number, 0 or more") starts.append(start) money(0, currency) per_minute = charger_power_watts * efficiency_basis_points remaining = energy_added_wh * 600000 total = remaining cost_numerator = 0 while remaining > 0: index = len(starts) - 1 for i, s in enumerate(starts): if s <= t: index = i nxt = starts[index + 1] if index + 1 < len(starts) else starts[0] length = nxt - t if nxt > t else nxt + DAY - t take = min(remaining, length * per_minute) cost_numerator += take * tariff[index].price_per_kwh_tenths remaining -= take t = (t + length) % DAY unit = 60 * efficiency_basis_points cost = int(round_div_big(str(cost_numerator), str(unit * 10000), mode)) return EvChargingCost( energy_drawn_wh=int(round_div_big(str(total), str(unit), "half-up")), minutes=int(round_div_big(str(total), str(per_minute), "up")), cost=money(cost, currency), )