from typing import Sequence from .education_weighted_grade_types import GradeComponent, WeightedGrade from .math_rational import add_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 weighted_grade(components: Sequence[GradeComponent], decimals: int, mode: RoundingMode) -> WeightedGrade: """Weighted percentage across components, summed exactly and rounded once. Each component adds mark x weight / (out_of x 100) percent: mark/out_of of the component, times weight/10000 of the whole, times 100 for a percentage. """ if len(components) == 0: raise ValueError("components must not be empty") 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,)) total = rational(0, 1) weights = 0 for c in components: if not _is_int(c.out_of) or c.out_of < 1: raise ValueError("outOf must be a whole number of 1 or more, received %s for %s" % (c.out_of, c.name)) if not _is_int(c.mark) or c.mark < 0 or c.mark > c.out_of: raise ValueError( "mark must be a whole number from 0 to outOf (%s), received %s for %s" % (c.out_of, c.mark, c.name) ) if not _is_int(c.weight_basis_points) or c.weight_basis_points < 0: raise ValueError( "weightBasisPoints must be a whole number of 0 or more, received %s for %s" % (c.weight_basis_points, c.name) ) weights += c.weight_basis_points total = add_rational(total, rational(c.mark * c.weight_basis_points, c.out_of * 100)) if weights != 10000: raise ValueError("weights must add up to 10000 basis points (100%%), received %s" % (weights,)) scaled = rational_to_integer(multiply_rational(total, rational(10**decimals, 1)), mode) return WeightedGrade(percent=total, scaled=scaled, decimals=decimals)