import re from typing import Tuple from .fleet_fuel_cost_types import DistanceUnit, EconomyUnit, FuelCost from .math_round_div import RoundingMode from .math_round_div_big import round_div_big from .money_amount import money from .units_convert import convert_units DECIMAL = re.compile(r"^[0-9]+(\.[0-9]+)?$") MAX_SAFE = 9007199254740991 def _decimal(name: str, text: str) -> Tuple[int, int]: """A plain decimal as an exact fraction: (digits without the point, power of ten).""" if not isinstance(text, str) or not DECIMAL.fullmatch(text): raise ValueError('%s must be a plain decimal of 0 or more, received "%s"' % (name, text)) point = text.find(".") places = 0 if point < 0 else len(text) - point - 1 return int(text.replace(".", "")), 10 ** places def fuel_cost( distance: str, distance_unit: DistanceUnit, economy: str, economy_unit: EconomyUnit, price_per_litre_tenths: int, currency: str, mode: RoundingMode, ) -> FuelCost: """The fuel a trip uses and what it costs. Everything stays an exact fraction until the one rounding step: litres come from the distance and economy, and the cost from those exact litres. Rounding the litres first, or using 4.546 for the imperial gallon, is a penny out often enough to break a reconciliation. The gallon and the mile come from units.convert, which defines them exactly. """ if distance_unit not in ("mi", "km"): raise ValueError('unknown distance unit "%s"' % (distance_unit,)) if economy_unit not in ("mpg-uk", "mpg-us", "l-per-100km"): raise ValueError('unknown economy unit "%s"' % (economy_unit,)) d_num, d_den = _decimal("distance", distance) e_num, e_den = _decimal("economy", economy) if e_num == 0: raise ValueError("economy must be greater than zero") if isinstance(price_per_litre_tenths, bool) or not isinstance(price_per_litre_tenths, int): raise TypeError("pricePerLitreTenths must be an integer, received %r" % (price_per_litre_tenths,)) if price_per_litre_tenths < 0: raise ValueError("pricePerLitreTenths must not be negative, received %d" % (price_per_litre_tenths,)) money(0, currency) km_num, km_den = _decimal("mile", convert_units("1", "mi", "km", 12)) if economy_unit == "l-per-100km": # litres = km x (L/100km) / 100 l_num = d_num * e_num * (km_num if distance_unit == "mi" else 1) l_den = d_den * e_den * 100 * (km_den if distance_unit == "mi" else 1) else: # litres = miles / mpg x litres per gallon g_num, g_den = _decimal("gallon", convert_units("1", "gal_imp" if economy_unit == "mpg-uk" else "gal_us", "L", 12)) l_num = d_num * e_den * g_num * (km_den if distance_unit == "km" else 1) l_den = d_den * e_num * g_den * (km_num if distance_unit == "km" else 1) cost = int(round_div_big(str(l_num * price_per_litre_tenths), str(l_den * 10), mode)) if cost > MAX_SAFE: raise ValueError("the fuel cost exceeds 2^53 - 1 minor units") thousandths = int(round_div_big(str(l_num * 1000), str(l_den), "half-up")) litres = "%d.%03d" % (thousandths // 1000, thousandths % 1000) return FuelCost(litres=litres, cost=money(cost, currency))