Skip to content

API reference

scholia.scheme

Marking scheme: criteria and their scored categories.

Band dataclass

A named grade band defined by an inclusive lower threshold.

Source code in src/scholia/scheme.py
@dataclass
class Band:
    """A named grade band defined by an inclusive lower threshold."""

    name: str
    min: int

Category dataclass

A single scored outcome for one criterion.

Source code in src/scholia/scheme.py
@dataclass
class Category:
    """A single scored outcome for one criterion."""

    marks: int
    feedback: str

Criterion dataclass

A criterion in the marking scheme, with its possible categories.

Source code in src/scholia/scheme.py
@dataclass
class Criterion:
    """A criterion in the marking scheme, with its possible categories."""

    categories: dict[str, Category] = field(default_factory=dict)

Scheme dataclass

The full marking scheme for one piece of assessment.

Source code in src/scholia/scheme.py
@dataclass
class Scheme:
    """The full marking scheme for one piece of assessment."""

    criteria: dict[str, Criterion] = field(default_factory=dict)
    bands: list[Band] = field(default_factory=list)

    @classmethod
    def load(cls, path: Path) -> Scheme:
        """Load a scheme from a YAML file."""
        with open(path) as file_handle:
            raw: dict[str, Any] = yaml.safe_load(file_handle) or {}
        bands_data = raw.pop("bands", None) or []
        bands = [Band(name=b["name"], min=b["min"]) for b in bands_data]
        criteria: dict[str, Criterion] = {}
        for criterion_name, categories_data in raw.items():
            categories: dict[str, Category] = {}
            for category_id, category_data in (categories_data or {}).items():
                categories[category_id] = Category(
                    marks=category_data["marks"],
                    feedback=category_data["feedback"],
                )
            criteria[criterion_name] = Criterion(categories=categories)
        return cls(criteria=criteria, bands=bands)

    def save(self, path: Path) -> None:
        """Write the scheme to a YAML file."""
        raw: dict[str, Any] = {}
        if self.bands:
            raw["bands"] = [{"name": b.name, "min": b.min} for b in self.bands]
        for criterion_name, criterion in self.criteria.items():
            raw[criterion_name] = {}
            for category_id, category in criterion.categories.items():
                raw[criterion_name][category_id] = {
                    "marks": category.marks,
                    "feedback": category.feedback,
                }
        with open(path, "w") as file_handle:
            yaml.dump(
                raw,
                file_handle,
                allow_unicode=True,
                default_flow_style=False,
                sort_keys=False,
            )

    def criterion_names(self) -> list[str]:
        """Return criterion names in definition order."""
        return list(self.criteria.keys())

    def get_category(self, criterion_name: str, category_id: str) -> Category | None:
        """Return the category, or ``None`` if not found."""
        criterion = self.criteria.get(criterion_name)
        if criterion is None:
            return None
        return criterion.categories.get(category_id)

criterion_names()

Return criterion names in definition order.

Source code in src/scholia/scheme.py
def criterion_names(self) -> list[str]:
    """Return criterion names in definition order."""
    return list(self.criteria.keys())

get_category(criterion_name, category_id)

Return the category, or None if not found.

Source code in src/scholia/scheme.py
def get_category(self, criterion_name: str, category_id: str) -> Category | None:
    """Return the category, or ``None`` if not found."""
    criterion = self.criteria.get(criterion_name)
    if criterion is None:
        return None
    return criterion.categories.get(category_id)

load(path) classmethod

Load a scheme from a YAML file.

Source code in src/scholia/scheme.py
@classmethod
def load(cls, path: Path) -> Scheme:
    """Load a scheme from a YAML file."""
    with open(path) as file_handle:
        raw: dict[str, Any] = yaml.safe_load(file_handle) or {}
    bands_data = raw.pop("bands", None) or []
    bands = [Band(name=b["name"], min=b["min"]) for b in bands_data]
    criteria: dict[str, Criterion] = {}
    for criterion_name, categories_data in raw.items():
        categories: dict[str, Category] = {}
        for category_id, category_data in (categories_data or {}).items():
            categories[category_id] = Category(
                marks=category_data["marks"],
                feedback=category_data["feedback"],
            )
        criteria[criterion_name] = Criterion(categories=categories)
    return cls(criteria=criteria, bands=bands)

save(path)

Write the scheme to a YAML file.

Source code in src/scholia/scheme.py
def save(self, path: Path) -> None:
    """Write the scheme to a YAML file."""
    raw: dict[str, Any] = {}
    if self.bands:
        raw["bands"] = [{"name": b.name, "min": b.min} for b in self.bands]
    for criterion_name, criterion in self.criteria.items():
        raw[criterion_name] = {}
        for category_id, category in criterion.categories.items():
            raw[criterion_name][category_id] = {
                "marks": category.marks,
                "feedback": category.feedback,
            }
    with open(path, "w") as file_handle:
        yaml.dump(
            raw,
            file_handle,
            allow_unicode=True,
            default_flow_style=False,
            sort_keys=False,
        )

scholia.students

Student roster and per-student category assignments.

Student dataclass

A single student and their category assignments.

Source code in src/scholia/students.py
@dataclass
class Student:
    """A single student and their category assignments."""

    student_id: str
    assignments: dict[str, str] = field(default_factory=dict)
    note: str = ""

Students dataclass

The full student roster.

Source code in src/scholia/students.py
@dataclass
class Students:
    """The full student roster."""

    students: list[Student] = field(default_factory=list)
    criterion_names: list[str] = field(default_factory=list)

    @classmethod
    def load(cls, path: Path) -> Students:
        """Load the roster from a CSV file.

        UTF-8 (with or without BOM) is tried first. For files in a
        legacy single-byte encoding (e.g. Mac Roman or Windows-1252
        exported by some university systems), ``charset-normalizer``
        detects the encoding; if detection fails or the detected
        encoding cannot decode the raw bytes, Mac Roman is used as a
        final fallback.
        """
        raw = path.read_bytes()
        try:
            text = raw.decode("utf-8-sig")
        except UnicodeDecodeError:
            from charset_normalizer import from_bytes as _from_bytes

            best = _from_bytes(raw).best()
            if best is not None:
                try:
                    text = raw.decode(best.encoding)
                except (UnicodeDecodeError, LookupError):
                    text = raw.decode("mac_roman")
            else:
                text = raw.decode("mac_roman")
        reader = csv.DictReader(io.StringIO(text))
        fieldnames = list(reader.fieldnames or [])
        criterion_names = [f for f in fieldnames if f not in ("student_id", "note")]
        students: list[Student] = []
        for row in reader:
            assignments = {
                criterion: row.get(criterion, "") for criterion in criterion_names
            }
            students.append(
                Student(
                    student_id=row["student_id"],
                    assignments=assignments,
                    note=row.get("note", ""),
                )
            )
        return cls(students=students, criterion_names=criterion_names)

    def save(self, path: Path) -> None:
        """Write the roster to a CSV file."""
        fieldnames = ["student_id"] + self.criterion_names + ["note"]
        with open(path, "w", newline="") as file_handle:
            writer = csv.DictWriter(file_handle, fieldnames=fieldnames)
            writer.writeheader()
            for student in self.students:
                row: dict[str, str] = {"student_id": student.student_id}
                for criterion in self.criterion_names:
                    row[criterion] = student.assignments.get(criterion, "")
                row["note"] = student.note
                writer.writerow(row)

    def sync_headers(self, criterion_names: list[str]) -> None:
        """Sync criterion columns with the given list.

        Criteria not yet in the roster are added with empty values.
        The column order is updated to match ``criterion_names``; columns
        already present but absent from ``criterion_names`` are appended
        at the end.
        """
        extra = [q for q in self.criterion_names if q not in criterion_names]
        new_order = list(criterion_names) + extra
        for student in self.students:
            for criterion in new_order:
                if criterion not in student.assignments:
                    student.assignments[criterion] = ""
        self.criterion_names = new_order

    def update(self, student_id: str, criterion: str, category: str) -> None:
        """Set a student's category for one criterion.

        If ``criterion`` is not yet a column in the roster, it is added.

        Raises:
            ValueError: If ``student_id`` is not in the roster.
        """
        for student in self.students:
            if student.student_id == student_id:
                if criterion not in self.criterion_names:
                    self.criterion_names.append(criterion)
                student.assignments[criterion] = category
                return
        raise ValueError(f"Student {student_id!r} not found")

    def get_student(self, student_id: str) -> Student | None:
        """Return the student with the given ID, or ``None``."""
        for student in self.students:
            if student.student_id == student_id:
                return student
        return None

get_student(student_id)

Return the student with the given ID, or None.

Source code in src/scholia/students.py
def get_student(self, student_id: str) -> Student | None:
    """Return the student with the given ID, or ``None``."""
    for student in self.students:
        if student.student_id == student_id:
            return student
    return None

load(path) classmethod

Load the roster from a CSV file.

UTF-8 (with or without BOM) is tried first. For files in a legacy single-byte encoding (e.g. Mac Roman or Windows-1252 exported by some university systems), charset-normalizer detects the encoding; if detection fails or the detected encoding cannot decode the raw bytes, Mac Roman is used as a final fallback.

Source code in src/scholia/students.py
@classmethod
def load(cls, path: Path) -> Students:
    """Load the roster from a CSV file.

    UTF-8 (with or without BOM) is tried first. For files in a
    legacy single-byte encoding (e.g. Mac Roman or Windows-1252
    exported by some university systems), ``charset-normalizer``
    detects the encoding; if detection fails or the detected
    encoding cannot decode the raw bytes, Mac Roman is used as a
    final fallback.
    """
    raw = path.read_bytes()
    try:
        text = raw.decode("utf-8-sig")
    except UnicodeDecodeError:
        from charset_normalizer import from_bytes as _from_bytes

        best = _from_bytes(raw).best()
        if best is not None:
            try:
                text = raw.decode(best.encoding)
            except (UnicodeDecodeError, LookupError):
                text = raw.decode("mac_roman")
        else:
            text = raw.decode("mac_roman")
    reader = csv.DictReader(io.StringIO(text))
    fieldnames = list(reader.fieldnames or [])
    criterion_names = [f for f in fieldnames if f not in ("student_id", "note")]
    students: list[Student] = []
    for row in reader:
        assignments = {
            criterion: row.get(criterion, "") for criterion in criterion_names
        }
        students.append(
            Student(
                student_id=row["student_id"],
                assignments=assignments,
                note=row.get("note", ""),
            )
        )
    return cls(students=students, criterion_names=criterion_names)

save(path)

Write the roster to a CSV file.

Source code in src/scholia/students.py
def save(self, path: Path) -> None:
    """Write the roster to a CSV file."""
    fieldnames = ["student_id"] + self.criterion_names + ["note"]
    with open(path, "w", newline="") as file_handle:
        writer = csv.DictWriter(file_handle, fieldnames=fieldnames)
        writer.writeheader()
        for student in self.students:
            row: dict[str, str] = {"student_id": student.student_id}
            for criterion in self.criterion_names:
                row[criterion] = student.assignments.get(criterion, "")
            row["note"] = student.note
            writer.writerow(row)

sync_headers(criterion_names)

Sync criterion columns with the given list.

Criteria not yet in the roster are added with empty values. The column order is updated to match criterion_names; columns already present but absent from criterion_names are appended at the end.

Source code in src/scholia/students.py
def sync_headers(self, criterion_names: list[str]) -> None:
    """Sync criterion columns with the given list.

    Criteria not yet in the roster are added with empty values.
    The column order is updated to match ``criterion_names``; columns
    already present but absent from ``criterion_names`` are appended
    at the end.
    """
    extra = [q for q in self.criterion_names if q not in criterion_names]
    new_order = list(criterion_names) + extra
    for student in self.students:
        for criterion in new_order:
            if criterion not in student.assignments:
                student.assignments[criterion] = ""
    self.criterion_names = new_order

update(student_id, criterion, category)

Set a student's category for one criterion.

If criterion is not yet a column in the roster, it is added.

Raises:

Type Description
ValueError

If student_id is not in the roster.

Source code in src/scholia/students.py
def update(self, student_id: str, criterion: str, category: str) -> None:
    """Set a student's category for one criterion.

    If ``criterion`` is not yet a column in the roster, it is added.

    Raises:
        ValueError: If ``student_id`` is not in the roster.
    """
    for student in self.students:
        if student.student_id == student_id:
            if criterion not in self.criterion_names:
                self.criterion_names.append(criterion)
            student.assignments[criterion] = category
            return
    raise ValueError(f"Student {student_id!r} not found")

scholia.marks

Compute numeric marks from category assignments and a scheme.

compute_criterion_marks(student, scheme)

Return each criterion's mark for a student.

Returns None for any criterion with no category assigned yet.

Raises:

Type Description
CategoryNotInSchemeError

if a non-empty category label recorded in students.csv does not exist in the corresponding criterion in scheme.yaml.

Source code in src/scholia/marks.py
def compute_criterion_marks(student: Student, scheme: Scheme) -> dict[str, int | None]:
    """Return each criterion's mark for a student.

    Returns ``None`` for any criterion with no category assigned yet.

    Raises:
        CategoryNotInSchemeError: if a non-empty category label recorded in
            ``students.csv`` does not exist in the corresponding criterion in
            ``scheme.yaml``.
    """
    result: dict[str, int | None] = {}
    for criterion_name in scheme.criterion_names():
        category_id = student.assignments.get(criterion_name, "")
        if not category_id:
            result[criterion_name] = None
            continue
        category = scheme.get_category(criterion_name, category_id)
        if category is None:
            criterion = scheme.criteria.get(criterion_name)
            valid_categories = list(criterion.categories.keys()) if criterion else []
            raise CategoryNotInSchemeError(
                student_id=student.student_id,
                criterion_name=criterion_name,
                category_id=category_id,
                valid_categories=valid_categories,
            )
        result[criterion_name] = category.marks
    return result

compute_total_marks(student, scheme)

Return the sum of all criterion marks for a student.

Returns None if any criterion is unassigned or the scheme is empty.

Source code in src/scholia/marks.py
def compute_total_marks(student: Student, scheme: Scheme) -> int | None:
    """Return the sum of all criterion marks for a student.

    Returns ``None`` if any criterion is unassigned or the scheme is empty.
    """
    criterion_marks = compute_criterion_marks(student, scheme)
    values = list(criterion_marks.values())
    if not values or any(value is None for value in values):
        return None
    return sum(value for value in values if value is not None)

scholia.feedback

Generate per-student feedback markdown files.

generate_all_feedback(students, scheme, feedback_dir)

Write feedback files for all students in the roster.

Source code in src/scholia/feedback.py
def generate_all_feedback(
    students: Students,
    scheme: Scheme,
    feedback_dir: Path,
) -> None:
    """Write feedback files for all students in the roster."""
    for student in students.students:
        generate_student_feedback(student, scheme, feedback_dir)

generate_student_feedback(student, scheme, feedback_dir)

Write a markdown feedback file to feedback_dir/<student_id>.md.

Source code in src/scholia/feedback.py
def generate_student_feedback(
    student: Student,
    scheme: Scheme,
    feedback_dir: Path,
) -> None:
    """Write a markdown feedback file to ``feedback_dir/<student_id>.md``."""
    feedback_dir.mkdir(parents=True, exist_ok=True)
    criterion_marks = compute_criterion_marks(student, scheme)
    total = compute_total_marks(student, scheme)

    lines: list[str] = [f"# Feedback: {student.student_id}", ""]

    if total is not None:
        lines += [f"**Total marks:** {total}", ""]
    else:
        lines += ["**Total marks:** incomplete", ""]

    lines += ["## Criterion breakdown", ""]

    for criterion_name, marks in criterion_marks.items():
        category_id = student.assignments.get(criterion_name, "")
        category = scheme.get_category(criterion_name, category_id)
        lines += [f"### {criterion_name}", ""]
        if marks is not None and category is not None:
            lines += [f"**Marks:** {marks}", "", category.feedback, ""]
        else:
            lines += ["**Marks:** not yet marked", ""]

    if student.note:
        lines += ["## Note", "", student.note, ""]

    (feedback_dir / f"{student.student_id}.md").write_text(
        "\n".join(lines), encoding="utf-8"
    )

scholia.stats

Generate summary statistics and charts for a marked cohort.

generate_marks_csv(students, scheme, output_path)

Write student IDs and total marks to a CSV file.

Students with incomplete marking have an empty total_marks cell.

Source code in src/scholia/stats.py
def generate_marks_csv(
    students: Students,
    scheme: Scheme,
    output_path: Path,
) -> None:
    """Write student IDs and total marks to a CSV file.

    Students with incomplete marking have an empty ``total_marks`` cell.
    """
    with open(output_path, "w", newline="") as file_handle:
        writer = csv.writer(file_handle)
        writer.writerow(["student_id", "total_marks"])
        for student in students.students:
            total = compute_total_marks(student, scheme)
            writer.writerow([student.student_id, "" if total is None else total])

generate_summary(students, scheme, output_path, charts_dir=None)

Write a cohort summary to output_path.

When charts_dir is provided, saves a distribution histogram, cumulative distribution, per-criterion boxplot, and (with at least two criteria and two complete students) a correlation heatmap.

Source code in src/scholia/stats.py
def generate_summary(
    students: Students,
    scheme: Scheme,
    output_path: Path,
    charts_dir: Path | None = None,
) -> None:
    """Write a cohort summary to ``output_path``.

    When ``charts_dir`` is provided, saves a distribution histogram,
    cumulative distribution, per-criterion boxplot, and (with at least two
    criteria and two complete students) a correlation heatmap.
    """
    bands = scheme.bands
    scheme_criterion_names = scheme.criterion_names()
    all_totals: list[int] = []
    per_criterion_categories: dict[str, dict[str, int]] = {
        q: {} for q in scheme_criterion_names
    }
    per_criterion_mark_values: dict[str, list[int]] = {
        q: [] for q in scheme_criterion_names
    }
    complete_mark_rows: list[list[int]] = []

    for student in students.students:
        total = compute_total_marks(student, scheme)
        criterion_marks = compute_criterion_marks(student, scheme)

        if total is not None:
            all_totals.append(total)
            complete_mark_rows.append(
                [
                    m
                    for q in scheme_criterion_names
                    if (m := criterion_marks[q]) is not None
                ]
            )

        for criterion_name in scheme_criterion_names:
            category_id = student.assignments.get(criterion_name, "")
            if category_id:
                counter = per_criterion_categories[criterion_name]
                counter[category_id] = counter.get(category_id, 0) + 1
            marks = criterion_marks.get(criterion_name)
            if marks is not None:
                per_criterion_mark_values[criterion_name].append(marks)

    lines: list[str] = ["# Marking summary", ""]

    if all_totals:
        count = len(all_totals)
        mean = statistics.mean(all_totals)
        std = statistics.stdev(all_totals) if count > 1 else 0.0
        minimum = min(all_totals)
        maximum = max(all_totals)
        if count >= 2:
            q1, median, q3 = statistics.quantiles(all_totals, n=4)
        else:
            q1 = median = q3 = float(all_totals[0])
        stat_rows = [
            ["Count", str(count)],
            ["Mean", f"{mean:.2f}"],
            ["Std dev", f"{std:.2f}"],
            ["Min", str(minimum)],
            ["Q1 (25%)", f"{q1:.2f}"],
            ["Median", f"{median:.2f}"],
            ["Q3 (75%)", f"{q3:.2f}"],
            ["Max", str(maximum)],
        ]
        lines += _md_table(["Statistic", "Value"], stat_rows) + [""]
        if bands:
            lines += _generate_band_table(all_totals, bands)
        if charts_dir is not None:
            lines += _generate_charts(
                all_totals,
                per_criterion_mark_values,
                scheme_criterion_names,
                complete_mark_rows,
                charts_dir,
                bands if bands else None,
            )
    else:
        lines += ["No complete marks yet.", ""]

    lines += ["## Per-criterion breakdown", ""]

    for criterion_name, category_counts in per_criterion_categories.items():
        criterion = scheme.criteria[criterion_name]
        lines += [f"### {criterion_name}", ""]
        for category_id, category in criterion.categories.items():
            count = category_counts.get(category_id, 0)
            label = category.feedback.removesuffix(".")
            lines.append(f"- {label}: {count} student(s) ({category.marks} marks)")
        lines.append("")

    output_path.write_text("\n".join(lines), encoding="utf-8")