use super::funejson::Value; use super::math_round_float::round_float; use super::money_amount::{money, money_from_value, money_to_value}; const MAX_SAFE: i128 = 9_007_199_254_740_991; // Half up for a non-negative numerator and a positive denominator. fn half_up(numerator: i128, denominator: i128) -> i64 { let q = (2 * numerator + denominator) / (2 * denominator); if q > MAX_SAFE { panic!("total cost is too large"); } q as i64 } /// Ration cost and dry matter intake. /// /// Weights are grams and prices minor units per tonne, so a feed's daily cost /// is exactly grams x price / 1,000,000 and every total is rounded once from /// the exact sum. Costing the group from a rounded daily cost multiplies the /// rounding: 467.5p a head a day is 1,402,500p for 100 cows over 30 days, not /// 468 x 3000 = 1,404,000p. /// /// # Panics /// Panics on an out-of-range quantity, DM, price, head or days, a price in /// another currency, or a total beyond 2^53 - 1 minor units. pub fn feed_ration(feeds: &[RationFeed], head: i64, days: i64, currency: &str) -> RationCost { if !(1..=1_000_000).contains(&head) { panic!("head must be a whole number from 1 to 1000000, received {}", head); } if !(1..=3660).contains(&days) { panic!("days must be a whole number from 1 to 3660, received {}", days); } money(0, currency); let mut grams: i128 = 0; let mut dry_matter: i128 = 0; let mut cost: i128 = 0; let mut lines: Vec = Vec::with_capacity(feeds.len()); for (i, feed) in feeds.iter().enumerate() { let n = i + 1; let kg = feed.fresh_kg_per_head; if !kg.is_finite() || kg < 0.0 || kg > 1000.0 { panic!("feed {}: freshKgPerHead must be a finite number from 0 to 1000, received {}", n, kg); } let g = (round_float(kg, 3) * 1000.0).round() as i128; let bp = feed.dry_matter_basis_points; if !(0..=10000).contains(&bp) { panic!("feed {}: dryMatterBasisPoints must be a whole number from 0 to 10000, received {}", n, bp); } let price = &feed.price_per_tonne; if price.currency != currency { panic!("feed {}: currency mismatch: {} and {}", n, price.currency, currency); } if !(0..=100_000_000).contains(&price.minor) { panic!("feed {}: pricePerTonne must be from 0 to 100000000 minor units, received {}", n, price.minor); } let line_dm = g * bp as i128; let line_cost = g * price.minor as i128; grams += g; dry_matter += line_dm; cost += line_cost; lines.push(RationLine { name: feed.name.clone(), fresh_kg_per_head: g as f64 / 1000.0, dry_matter_kg_per_head: half_up(line_dm, 10000) as f64 / 1000.0, cost_per_head: money(half_up(line_cost, 1_000_000), currency), }); } RationCost { lines, fresh_kg_per_head: grams as f64 / 1000.0, dry_matter_kg_per_head: half_up(dry_matter, 10000) as f64 / 1000.0, dry_matter_basis_points: if grams == 0 { None } else { Some(half_up(dry_matter, grams)) }, cost_per_head_per_day: money(half_up(cost, 1_000_000), currency), total_cost: money(half_up(cost * head as i128 * days as i128, 1_000_000), currency), cost_per_tonne_dry_matter: if dry_matter == 0 { None } else { Some(money(half_up(cost * 10000, dry_matter), currency)) }, } } pub fn ration_cost_to_value(r: &RationCost) -> Value { let lines = r .lines .iter() .map(|l| { Value::obj(vec![ ("name", Value::str(&l.name)), ("freshKgPerHead", Value::Float(l.fresh_kg_per_head)), ("dryMatterKgPerHead", Value::Float(l.dry_matter_kg_per_head)), ("costPerHead", money_to_value(&l.cost_per_head)), ]) }) .collect(); Value::obj(vec![ ("lines", Value::Arr(lines)), ("freshKgPerHead", Value::Float(r.fresh_kg_per_head)), ("dryMatterKgPerHead", Value::Float(r.dry_matter_kg_per_head)), ("dryMatterBasisPoints", r.dry_matter_basis_points.map_or(Value::Null, Value::Int)), ("costPerHeadPerDay", money_to_value(&r.cost_per_head_per_day)), ("totalCost", money_to_value(&r.total_cost)), ("costPerTonneDryMatter", r.cost_per_tonne_dry_matter.as_ref().map_or(Value::Null, money_to_value)), ]) } fn whole(v: &Value, message: String) -> i64 { match v { Value::Int(i) => *i, _ => panic!("{}, received {:?}", message, v), } } pub fn fune_vector(args: &[Value]) -> Value { let feeds: Vec = args[0] .as_arr() .iter() .enumerate() .map(|(i, v)| RationFeed { name: v.get("name").as_str().to_string(), fresh_kg_per_head: v.get("freshKgPerHead").as_f64(), dry_matter_basis_points: whole( v.get("dryMatterBasisPoints"), format!("feed {}: dryMatterBasisPoints must be a whole number from 0 to 10000", i + 1), ), price_per_tonne: money_from_value(v.get("pricePerTonne")), }) .collect(); let head = whole(&args[1], "head must be a whole number from 1 to 1000000".to_string()); let days = whole(&args[2], "days must be a whole number from 1 to 3660".to_string()); ration_cost_to_value(&feed_ration(&feeds, head, days, args[3].as_str())) }