use super::funejson::Value; use super::education_degree_classification_data::DEGREE_CLASSES; use super::math_rational::{ add_rational, divide_rational, multiply_rational, rational, rational_to_integer, rational_to_value, }; /// Honours classification: credit-weighted mean per level, levels combined by /// their weights, rounded once, then banded. /// /// # Panics /// Panics on a mark outside 0 to 100, credits below 1, an empty, duplicated or /// all-zero weighting, a weighted level with no modules, decimals outside 0 to /// 6, or an unknown rounding mode. pub fn degree_classification( modules: &[ModuleMark], level_weights: &[LevelWeight], decimals: i64, mode: &str, ) -> DegreeClassification { if level_weights.is_empty() { panic!("levelWeights must name at least one level"); } if !(0..=6).contains(&decimals) { panic!("decimals must be a whole number from 0 to 6, received {}", decimals); } for m in modules { if m.mark < 0 || m.mark > 100 { panic!("mark must be a whole number from 0 to 100, received {}", m.mark); } if m.credits < 1 { panic!("credits must be a whole number of 1 or more, received {}", m.credits); } } let mut total_weight: i64 = 0; let mut sum = rational(0, 1); let mut counted_credits: i64 = 0; for (i, lw) in level_weights.iter().enumerate() { if lw.weight < 0 { panic!("weight must be a whole number of 0 or more, received {} for level {}", lw.weight, lw.level); } if level_weights[..i].iter().any(|o| o.level == lw.level) { panic!("level {} appears twice in levelWeights", lw.level); } if lw.weight == 0 { continue; } let mut marks: i64 = 0; let mut credits: i64 = 0; for m in modules.iter().filter(|m| m.level == lw.level) { marks += m.mark * m.credits; credits += m.credits; } if credits == 0 { panic!("no modules at level {}, which levelWeights counts", lw.level); } counted_credits += credits; total_weight += lw.weight; sum = add_rational(&sum, &multiply_rational(&rational(marks, credits), &rational(lw.weight, 1))); } if total_weight == 0 { panic!("levelWeights must not all be zero"); } let average = divide_rational(&sum, &rational(total_weight, 1)); let scale = 10i64.pow(decimals as u32); let scaled = rational_to_integer(&multiply_rational(&average, &rational(scale, 1)), mode); let mut bands: Vec<_> = DEGREE_CLASSES.iter().collect(); bands.sort_by(|a, b| b.min_mark.cmp(&a.min_mark)); let band = bands .iter() .find(|b| scaled >= b.min_mark * scale) .copied() .unwrap_or(bands[bands.len() - 1]); DegreeClassification { average, scaled, decimals, classification: band.classification.to_string(), label: band.label.to_string(), counted_credits, } } fn whole(value: &Value, message: &str) -> i64 { match value { Value::Float(f) if f.fract() != 0.0 => panic!("{}, received {}", message, f), _ => value.as_i64(), } } pub fn degree_classification_to_value(d: &DegreeClassification) -> Value { Value::obj(vec![ ("average", rational_to_value(&d.average)), ("scaled", Value::Int(d.scaled)), ("decimals", Value::Int(d.decimals)), ("classification", Value::str(&d.classification)), ("label", Value::str(&d.label)), ("countedCredits", Value::Int(d.counted_credits)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let modules: Vec = args[0] .as_arr() .iter() .map(|v| ModuleMark { level: v.get("level").as_i64(), mark: whole(v.get("mark"), "mark must be a whole number from 0 to 100"), credits: whole(v.get("credits"), "credits must be a whole number of 1 or more"), }) .collect(); let weights: Vec = args[1] .as_arr() .iter() .map(|v| LevelWeight { level: v.get("level").as_i64(), weight: whole(v.get("weight"), "weight must be a whole number of 0 or more"), }) .collect(); let decimals = whole(&args[2], "decimals must be a whole number from 0 to 6"); degree_classification_to_value(°ree_classification(&modules, &weights, decimals, args[3].as_str())) }