education.degree-classification@1.0.0
impl/typescript.ts
2,966 bytes · the TypeScript implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type Rational, addRational, divideRational, multiplyRational, rational, rationalToInteger } from "./math_rational.ts"; ← from math.rational ^1.0.0 · built alongside by fune
import { type RoundingMode } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { DEGREE_CLASSES } from "./education_degree_classification_data.ts"; ← this capability’s own data, compiled from data/degree-classes.json into the same file by fune build
import { type DegreeClass, type DegreeClassification, type LevelWeight, type ModuleMark } from "./education_degree_classification_types.ts";
/**
* Honours classification: credit-weighted mean per level, levels combined by
* their weights, rounded once, then banded.
*/
export function degreeClassification(
modules: readonly ModuleMark[],
levelWeights: readonly LevelWeight[],
decimals: number,
mode: RoundingMode,
): DegreeClassification {
if (levelWeights.length === 0) {
throw new RangeError("levelWeights must name at least one level");
}
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 6) {
throw new RangeError(`decimals must be a whole number from 0 to 6, received ${decimals}`);
}
for (const m of modules) {
if (!Number.isInteger(m.mark) || m.mark < 0 || m.mark > 100) {
throw new RangeError(`mark must be a whole number from 0 to 100, received ${m.mark}`);
}
if (!Number.isInteger(m.credits) || m.credits < 1) {
throw new RangeError(`credits must be a whole number of 1 or more, received ${m.credits}`);
}
}
let totalWeight = 0;
let sum: Rational = rational(0, 1);
let countedCredits = 0;
for (let i = 0; i < levelWeights.length; i++) {
const lw = levelWeights[i];
if (!Number.isInteger(lw.weight) || lw.weight < 0) {
throw new RangeError(`weight must be a whole number of 0 or more, received ${lw.weight} for level ${lw.level}`);
}
for (let j = 0; j < i; j++) {
if (levelWeights[j].level === lw.level) throw new RangeError(`level ${lw.level} appears twice in levelWeights`);
}
if (lw.weight === 0) continue;
let marks = 0;
let credits = 0;
for (const m of modules) {
if (m.level === lw.level) {
marks += m.mark * m.credits;
credits += m.credits;
}
}
if (credits === 0) {
throw new RangeError(`no modules at level ${lw.level}, which levelWeights counts`);
}
countedCredits += credits;
totalWeight += lw.weight;
sum = addRational(sum, multiplyRational(rational(marks, credits), rational(lw.weight, 1)));
}
if (totalWeight === 0) {
throw new RangeError("levelWeights must not all be zero");
}
const average = divideRational(sum, rational(totalWeight, 1));
const scale = 10 ** decimals;
const scaled = rationalToInteger(multiplyRational(average, rational(scale, 1)), mode);
const bands = [...DEGREE_CLASSES].sort((a, b) => b.minMark - a.minMark);
const band = bands.find((b) => scaled >= b.minMark * scale) ?? bands[bands.length - 1];
return {
average,
scaled,
decimals,
classification: band.classification as DegreeClass,
label: band.label,
countedCredits,
};
}