from typing import Sequence from .education_degree_classification_data import DEGREE_CLASSES from .education_degree_classification_types import DegreeClassification, LevelWeight, ModuleMark from .math_rational import add_rational, divide_rational, multiply_rational, rational, rational_to_integer from .math_round_div import RoundingMode def _is_int(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) def degree_classification( modules: Sequence[ModuleMark], level_weights: Sequence[LevelWeight], decimals: int, mode: RoundingMode ) -> DegreeClassification: """Honours classification: credit-weighted mean per level, levels combined by their weights, rounded once, then banded.""" if len(level_weights) == 0: raise ValueError("levelWeights must name at least one level") if not _is_int(decimals) or decimals < 0 or decimals > 6: raise ValueError("decimals must be a whole number from 0 to 6, received %s" % (decimals,)) for m in modules: if not _is_int(m.mark) or m.mark < 0 or m.mark > 100: raise ValueError("mark must be a whole number from 0 to 100, received %s" % (m.mark,)) if not _is_int(m.credits) or m.credits < 1: raise ValueError("credits must be a whole number of 1 or more, received %s" % (m.credits,)) total_weight = 0 total = rational(0, 1) counted_credits = 0 for i, lw in enumerate(level_weights): if not _is_int(lw.weight) or lw.weight < 0: raise ValueError("weight must be a whole number of 0 or more, received %s for level %s" % (lw.weight, lw.level)) for j in range(i): if level_weights[j].level == lw.level: raise ValueError("level %s appears twice in levelWeights" % (lw.level,)) if lw.weight == 0: continue marks = 0 credits = 0 for m in modules: if m.level == lw.level: marks += m.mark * m.credits credits += m.credits if credits == 0: raise ValueError("no modules at level %s, which levelWeights counts" % (lw.level,)) counted_credits += credits total_weight += lw.weight total = add_rational(total, multiply_rational(rational(marks, credits), rational(lw.weight, 1))) if total_weight == 0: raise ValueError("levelWeights must not all be zero") average = divide_rational(total, rational(total_weight, 1)) scale = 10**decimals scaled = rational_to_integer(multiply_rational(average, rational(scale, 1)), mode) bands = sorted(DEGREE_CLASSES, key=lambda b: -b.min_mark) band = next((b for b in bands if scaled >= b.min_mark * scale), bands[-1]) return DegreeClassification( average=average, scaled=scaled, decimals=decimals, classification=band.classification, label=band.label, counted_credits=counted_credits, )