use super::funejson::Value; use super::fleet_emissions_data::GHG_FACTORS; const MAX_SAFE: i128 = 9007199254740991; fn format_factor(factor: i64) -> String { format!("{}.{:05}", factor / 100000, factor % 100000) } /// Split plain decimal text into its whole and fractional digits, or None. fn split_decimal(text: &str) -> Option<(&str, &str)> { let (whole, fraction) = match text.find('.') { Some(i) => (&text[..i], &text[i + 1..]), None => (text, ""), }; let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit()); if whole.is_empty() || !digits(whole) || !digits(fraction) || (text.contains('.') && fraction.is_empty()) { return None; } Some((whole, fraction)) } /// Scope 1 emissions for fuel burned or distance driven, from the UK /// government's conversion factors for company reporting. /// /// The quantity is decimal text and the factor an integer in 1e-5 kg, so the /// product is exact and rounded once, to the gram. Multiplying floats instead /// drifts in the last place and can round a half-gram the wrong way. /// /// # Panics /// Panics on an unknown unit, year or activity, a unit the activity has no /// factor for, a malformed or negative quantity, or a result beyond 2^53 - 1. pub fn fleet_emissions(activity: &str, quantity: &str, unit: &str, factor_year: i64) -> FleetEmissions { if unit != "litres" && unit != "km" && unit != "miles" { panic!("unknown unit \"{}\": use litres, km or miles", unit); } let (whole, fraction) = match split_decimal(quantity) { Some(parts) => parts, None => panic!( "quantity must be plain decimal text, not negative, received \"{}\"", quantity ), }; let all_digits = format!("{}{}", whole, fraction); let significant = all_digits.trim_start_matches('0'); if significant.len() > 15 || fraction.len() > 9 { panic!( "quantity must have at most 15 significant digits and 9 decimal places, received \"{}\"", quantity ); } let mut found: Option = None; let mut year_known = false; let mut activity_known = false; for row in GHG_FACTORS { if row.factor_year != factor_year { continue; } year_known = true; if row.activity != activity { continue; } activity_known = true; if row.unit == unit { found = Some(row.factor); } } if !year_known { panic!("no conversion factors for {}: this package carries 2023 to 2026", factor_year); } if !activity_known { panic!("unknown activity \"{}\" in the {} factors", activity, factor_year); } let factor = match found { Some(f) => f, None => panic!("no conversion factor for {} per {} in {}", activity, unit, factor_year), }; // grams = quantity x factor x 1e-5 kg x 1000 g/kg = quantity x factor / 100. // At most 15 digits times a factor below 1e7 fits i128 with room to spare. let value: i128 = if significant.is_empty() { 0 } else { significant.parse().unwrap() }; let numerator = value * factor as i128; let denominator = 10i128.pow(fraction.len() as u32) * 100; let grams = (2 * numerator + denominator) / (2 * denominator); if grams > MAX_SAFE { panic!("the result exceeds 2^53 - 1 grams"); } FleetEmissions { co2e_grams: grams as i64, factor: format_factor(factor), factor_year, activity: activity.to_string(), unit: unit.to_string(), } } pub fn fleet_emissions_to_value(e: &FleetEmissions) -> Value { Value::obj(vec![ ("co2eGrams", Value::Int(e.co2e_grams)), ("factor", Value::str(&e.factor)), ("factorYear", Value::Int(e.factor_year)), ("activity", Value::str(&e.activity)), ("unit", Value::str(&e.unit)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let quantity = match &args[1] { Value::Str(s) => s.as_str(), other => panic!( "quantity must be plain decimal text, not negative, received {:?}", other ), }; let year = match &args[3] { Value::Int(i) => *i, other => panic!("factorYear must be a whole year, received {:?}", other), }; fleet_emissions_to_value(&fleet_emissions(args[0].as_str(), quantity, args[2].as_str(), year)) }