Reconstructing Canvas Final Grades in Python
When a grade appeal, an accreditation export, or a retention model needs to explain a student’s Canvas course grade, trusting the single computed_current_score field the API hands back is not enough — you have to reproduce the arithmetic that produced it. Canvas computes a course total by scoring each assignment group independently, then combining those group percentages with the weights an instructor assigned in the “Assignment Groups” panel. This guide walks through reconstructing that final grade in Python from the raw building blocks (assignment groups, their weights, and each submission) so your recomputed number reconciles with the Canvas UI to the last decimal place. It applies the entity model from Canvas Gradebook Data Structure and the group-weight formula defined in Weighted Grade Calculation Engines, and it assumes you already have typed rows in hand from How to Parse Canvas Gradebook JSON with Pandas.
The grade Canvas displays is a weighted mean of per-group fractions. With group weights (each a percentage the instructor set) and each group’s earned and possible points and after ungraded and excused work is removed, the reconstructed grade is:
The denominator is the sum of the weights of only the groups that have graded work. A group with nothing graded yet drops out of both sums, which is exactly what makes a Canvas “current” grade rise and fall as the term progresses. Getting that denominator wrong — leaving an empty group’s weight in the divisor — is the single most common reason a hand-rolled total disagrees with the Canvas display.
Prerequisites
Confirm each of these before running the reconstruction — the script assumes they are all in place.
Step-by-step implementation
1. Load the policy and the evidence as separate inputs. Keep the assignment groups (weights, and which assignments belong to each) apart from the submissions (per-assignment scores), because the same submissions must be replayable against a past weight configuration during an appeal. This mirrors the policy-versus-evidence split described in Weighted Grade Calculation Engines.
2. Convert group_weight from a percentage to a fraction once, at the boundary. Canvas stores group weights as percentages (40.0, not 0.40), so dividing by 100 immediately after ingestion prevents a double-scaling bug where a grade lands at a hundredth of its true value.
3. Route every submission to its group and split ungraded from excused. A submission with score is None is ungraded — it must not enter the group total at all, because treating null as zero is the classic error that deflates in-progress grades. A submission with excused is True also leaves both the earned and possible sides, since excused work is removed from the denominator rather than scored.
4. Compute one fraction per group as earned over possible. Sum the surviving scores into and the matching points_possible into , then form ; doing the division per group (not per assignment) is what lets unequal-point assignments combine correctly inside a group.
5. Drop groups that ended up with no graded work. If a group’s possible-point sum is zero, exclude it from the weighted mean and from the weight denominator, because Canvas redistributes an empty group’s weight across the groups that do have grades — leaving it in the divisor understates every current grade.
6. Combine the surviving group fractions as a weighted mean. Divide the weighted sum of fractions by the sum of the surviving weights so the result is normalized even when groups have dropped out, matching the formula above.
7. Quantize once and reconcile against the expected value. Do all arithmetic in decimal.Decimal, quantize the final percentage with ROUND_HALF_UP, and assert it is within 1e-4 of the Canvas-displayed number so a byte-for-byte match is provable rather than eyeballed.
Complete runnable code block
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from decimal import Decimal, ROUND_HALF_UP, getcontext
getcontext().prec = 28
TOLERANCE = Decimal("0.0001") # reconcile to the Canvas UI within 1e-4
@dataclass(frozen=True)
class AssignmentGroup:
group_id: int
group_weight_pct: Decimal # Canvas stores 40.0, not 0.40
assignment_ids: tuple[int, ...] # members of this group
@property
def weight(self) -> Decimal: # fractional weight used in the mean
return self.group_weight_pct / Decimal(100)
@dataclass(frozen=True)
class Submission:
assignment_id: int
score: Decimal | None # None => ungraded, excluded entirely
possible: Decimal # assignment points_possible
excused: bool = False
def tokenize(student_id: str, salt: str = "canvas-recon") -> str:
"""Salted SHA-256 — the raw student id never enters the output row."""
return hashlib.sha256(f"{salt}:{student_id}".encode("utf-8")).hexdigest()
def _group_fraction(group: AssignmentGroup,
subs: dict[int, Submission]) -> tuple[Decimal, Decimal]:
"""Return (earned, possible) for one group after removing null/excused work."""
earned = Decimal(0)
possible = Decimal(0)
for aid in group.assignment_ids:
s = subs.get(aid)
# Ungraded (null score) and excused work touch neither side of the ratio.
if s is None or s.score is None or s.excused:
continue
earned += s.score
possible += s.possible
return earned, possible
def reconstruct_final_grade(student_id: str,
groups: list[AssignmentGroup],
submissions: list[Submission]) -> dict:
"""Rebuild the Canvas course total as a weighted mean of per-group fractions."""
subs_by_id = {s.assignment_id: s for s in submissions}
weighted_sum = Decimal(0) # numerator: Σ w_g * (e_g / m_g)
live_weight = Decimal(0) # denominator: Σ w_g over groups with graded work
counted = 0
for group in groups:
earned, possible = _group_fraction(group, subs_by_id)
if possible == 0:
continue # empty group drops out of both sums entirely
fraction = earned / possible
weighted_sum += group.weight * fraction
live_weight += group.weight
counted += 1
if live_weight == 0: # nothing graded yet: no defined current grade
grade_pct = Decimal(0)
else:
grade_pct = (weighted_sum / live_weight) * Decimal(100)
return {
"student_token": tokenize(student_id),
"grade_pct": grade_pct.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP),
"groups_counted": counted,
"live_weight_pct": (live_weight * Decimal(100)),
}
def reconcile(result: dict, expected_pct: Decimal) -> None:
"""Fail loudly if the reconstruction disagrees with the Canvas-displayed value."""
delta = abs(result["grade_pct"] - expected_pct)
assert delta <= TOLERANCE, (
f"reconstruction {result['grade_pct']} != Canvas {expected_pct} "
f"(|Δ|={delta} > {TOLERANCE}); check empty-group and excused handling"
)
if __name__ == "__main__":
# Two weighted groups: Homework 40%, Exams 60%. A third group (Projects, 0%
# graded) is present but empty, so its weight must redistribute out.
groups = [
AssignmentGroup(101, Decimal("40.0"), (1, 2, 3)),
AssignmentGroup(102, Decimal("60.0"), (4, 5)),
AssignmentGroup(103, Decimal("0.0"), (6,)), # unweighted / empty
]
submissions = [
Submission(1, Decimal("9"), Decimal("10")), # HW: 9/10
Submission(2, Decimal("8"), Decimal("10")), # HW: 8/10
Submission(3, None, Decimal("10")), # HW: ungraded
Submission(4, Decimal("46"), Decimal("50")), # Exam: 46/50
Submission(5, Decimal("0"), Decimal("50"), excused=True), # Exam: excused
]
# Homework fraction = 17/20 = 0.85; Exam fraction = 46/50 = 0.92 (excused dropped).
# Grade = (0.40*0.85 + 0.60*0.92) / (0.40 + 0.60) * 100 = 89.2000
result = reconstruct_final_grade("stu-placeholder-8842", groups, submissions)
reconcile(result, Decimal("89.2000"))
print(f"token : {result['student_token'][:16]}…")
print(f"reconstructed : {result['grade_pct']}%")
print(f"groups counted : {result['groups_counted']}")
print("reconciled OK against Canvas UI value 89.2000")
Verification and output validation
Confirm the reconstruction before you treat its output as canonical:
- It reconciles. The
reconcile()assertion is the primary gate — a passing run proves|G_final − expected| ≤ 1e-4against the number a human read off the Canvas Grades page. - Empty groups redistribute. With the sample data,
groups_countedis2andlive_weight_pctis100, not100spread over three groups — the empty Projects group left both sums. - Null is not zero. Change assignment
3’s score fromNonetoDecimal("0")and the Homework fraction drops from0.85to17/30; if your real run matches Canvas only after that change, an ungraded item is being scored as a zero somewhere upstream. - Excused leaves the denominator. Flip submission
5’sexcusedtoFalseand the Exam denominator jumps to 100 points, dropping the grade — the excused item must vanish from both sides, not count as0/50. - Token, not identifier.
result["student_token"]is a 64-char hex digest; assertlen(result["student_token"]) == 64and that the rawstudent_idappears nowhere in the output dict.
Troubleshooting
- Reconstructed grade is roughly 1/100th of the Canvas value. You used
group_weight(a percentage like40.0) directly as a fraction. Divide it by 100 once at the boundary — theweightproperty in the script does exactly this, so pass raw percentages intogroup_weight_pct. - In-progress grade is too low by several points. An empty group’s weight is still in the denominator. Skip any group whose
possible == 0in both the weighted sum andlive_weight; Canvas redistributes that weight rather than scoring the group as zero. - Grade matches early in the term but drifts later. Ungraded submissions (
score is None) are being coerced to zero as more work is assigned but not yet graded. Keep null out of the group total entirely — the_group_fractionguard handles it — as also detailed in How to Parse Canvas Gradebook JSON with Pandas. - Excused students look worse than the UI shows. An
excused: truesubmission arrives withscore: null; if you filter only on null you may still be adding itspossiblepoints. Testexcusedexplicitly and drop the row from earned and possible together. AssertionErrorwith a delta near0.005. Float drift, or quantizing too early. Do every step indecimal.Decimaland quantize only the final percentage withROUND_HALF_UP, exactly as Weighted Grade Calculation Engines prescribes.- A submission scores against the wrong denominator. The assignment was routed to the wrong group, so its
points_possibleinflated another group’s . Verify eachassignment_idappears in exactly one group’sassignment_idstuple before summing — using the field definitions from Canvas Gradebook Data Structure.
Related
- Weighted Grade Calculation Engines — the parent engine, the group-weight formula, and the decimal-arithmetic rules this reconstruction follows.
- Handling Dropped Lowest Scores in Weighted Grades — extends this reconstruction with per-group “drop lowest N” resolution.
- Canvas Gradebook Data Structure — the assignment-group, weight, and submission field definitions this script consumes.
- How to Parse Canvas Gradebook JSON with Pandas — produces the typed submission rows this reconstruction reads.
Part of: Weighted Grade Calculation Engines