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 wgw_g (each a percentage the instructor set) and each group’s earned and possible points ege_g and mgm_g after ungraded and excused work is removed, the reconstructed grade is:

Gfinal=gwg(pg/mg)gwgwherepg=eg,pgmg[0,1+]G_{\text{final}} = \frac{\sum_{g} w_g \cdot (p_g / m_g)}{\sum_{g} w_g} \quad\text{where}\quad p_g = e_g,\; \frac{p_g}{m_g} \in [0, 1+]

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.

Canvas final-grade reconstruction pipeline A six-stage pipeline that rebuilds a Canvas course total. Stage one loads assignment groups with their weights and every submission. Stage two removes ungraded submissions where score is null and excused submissions, so neither touches the group total. Stage three sums earned and possible points per group and forms the per-group fraction. Stage four keeps only groups that have graded work, so an empty group drops out of both the numerator and the weight denominator. Stage five combines the surviving group fractions with their weights as a weighted mean. Stage six quantizes the result and asserts it equals the expected Canvas value within one ten-thousandth. 1 Load groups + weights + submissions group_weight is a percent, not a fraction 2 Drop ungraded (null) and excused null ≠ 0; excused leaves numerator and denominator 3 Sum per group → e_g / m_g earned over possible, one fraction per group 4 Keep only groups with graded work empty group leaves the weight denominator too 5 Weighted mean of surviving fractions Σ w_g (e_g/m_g) ÷ Σ w_g 6 Quantize + reconcile assert |G_final − expected| ≤ 1e-4 vs the Canvas UI

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 ege_g and the matching points_possible into mgm_g, then form pg/mgp_g/m_g; 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 GfinalG_{\text{final}} 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

python
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-4 against the number a human read off the Canvas Grades page.
  • Empty groups redistribute. With the sample data, groups_counted is 2 and live_weight_pct is 100, not 100 spread over three groups — the empty Projects group left both sums.
  • Null is not zero. Change assignment 3’s score from None to Decimal("0") and the Homework fraction drops from 0.85 to 17/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’s excused to False and the Exam denominator jumps to 100 points, dropping the grade — the excused item must vanish from both sides, not count as 0/50.
  • Token, not identifier. result["student_token"] is a 64-char hex digest; assert len(result["student_token"]) == 64 and that the raw student_id appears nowhere in the output dict.

Troubleshooting

  • Reconstructed grade is roughly 1/100th of the Canvas value. You used group_weight (a percentage like 40.0) directly as a fraction. Divide it by 100 once at the boundary — the weight property in the script does exactly this, so pass raw percentages into group_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 == 0 in both the weighted sum and live_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_fraction guard handles it — as also detailed in How to Parse Canvas Gradebook JSON with Pandas.
  • Excused students look worse than the UI shows. An excused: true submission arrives with score: null; if you filter only on null you may still be adding its possible points. Test excused explicitly and drop the row from earned and possible together.
  • AssertionError with a delta near 0.005. Float drift, or quantizing too early. Do every step in decimal.Decimal and quantize only the final percentage with ROUND_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_possible inflated another group’s mgm_g. Verify each assignment_id appears in exactly one group’s assignment_ids tuple before summing — using the field definitions from Canvas Gradebook Data Structure.

Part of: Weighted Grade Calculation Engines