Source code for pykubegrader.grade_reports.grade_report

# TODO: if not due yet and score is 0, make NAN, fix the rendering

import os
from datetime import datetime
from itertools import product

import numpy as np
import pandas as pd
from IPython.display import display

from pykubegrader.grade_reports.assignments import (
    Assignment,
)
from pykubegrader.grade_reports.assignments import (
    assignment_type as AssignmentType,
)
from pykubegrader.grade_reports.grading_config import (
    aliases,
    assignment_type_list,
    custom_grade_adjustments,
    dropped_assignments,
    duplicated_scores,
    exclude_from_running_avg,
    globally_exempted_assignments,
    grade_ranges,
    max_week,
    optional_drop_assignments,
    optional_drop_week,
)
from pykubegrader.telemetry import get_assignments_submissions


[docs] class GradeReport: """Class to generate a grade report for a course and perform grade calculations for each student.""" def __init__( self, start_date="2025-01-06", verbose=True, params=None, display_=True ): """Initializes an instance of the GradeReport class. Args: start_date (str, optional): The start date of the course. Defaults to "2025-01-06". verbose (bool, optional): Indicates if verbose output should be displayed. Defaults to True. """ self.assignments, self.student_subs = get_assignments_submissions(params=params) try: self.student_name = params.get("username", None) except Exception: self.student_name = os.environ.get("JUPYTERHUB_USER", None) self.max_week = max_week if max_week else self.get_num_weeks() self.start_date = start_date self.verbose = verbose self.assignment_type_list = assignment_type_list self.aliases = aliases self.globally_exempted_assignments = globally_exempted_assignments self.dropped_assignments = dropped_assignments self.optional_drop_week = optional_drop_week self.optional_drop_assignments = optional_drop_assignments self.excluded_from_running_avg = exclude_from_running_avg # assignments that have been dropped for a given students. self.student_assignments_dropped = [] self.setup_grades_df() self.build_assignments() self.update_global_exempted_assignments() self.calculate_grades() self.update_assignments_not_due_yet() self.calculate_grades() self.duplicate_scores() self.drop_lowest_n_for_types(1) self.calculate_grades() self.duplicate_scores() self.update_weekly_table() self._build_running_avg() self.check_optional_drop_assignments() self.calculate_grades() self.duplicate_scores() self.update_weekly_table() self._build_running_avg() self._calculate_final_average() df = self.highlight_nans(self.weekly_grades_df, self.weekly_grades_df_display) if display_: try: display(df) display(self.weighted_average_grades) except: # noqa: E722 pass
[docs] def check_optional_drop_assignments(self): """ Checks if the optional drop assignments are valid. """ for assignment in self.graded_assignments: if (assignment.name, assignment.week) in self.optional_drop_assignments: if ( self.weekly_grades_df_display.loc["Running Avg", assignment.name] > assignment.score ): assignment.exempted = True
[docs] @staticmethod def highlight_nans(nan_df, display_df, color="red"): """ Highlights NaN values from nan_df on display_df. Parameters: nan_df (pd.DataFrame): DataFrame containing NaNs to be highlighted. display_df (pd.DataFrame): DataFrame to be recolored. color (str): Background color for NaNs. Default is 'red'. Returns: pd.io.formats.style.Styler: Styled DataFrame with NaNs highlighted. """ # Ensure both DataFrames have the same index and columns nan_mask = nan_df.isna().reindex_like(display_df) # Function to apply the highlight conditionally def apply_highlight(row): return [ f"background-color: {color}" if nan_mask.loc[row.name, col] else "" for col in row.index ] # Apply the highlighting row-wise styled_df = display_df.style.apply(apply_highlight, axis=1) return styled_df
[docs] def update_assignments_not_due_yet(self): """ Updates the score of assignments that are not due yet to NaN. """ for assignment in self.graded_assignments: if ( assignment.due_date and assignment.name not in self.excluded_from_running_avg ): # Convert due date to datetime object due_date = datetime.fromisoformat( assignment.due_date.replace("Z", "+00:00") ) if due_date > datetime.now(due_date.tzinfo) and assignment.score == 0: assignment.score = np.nan assignment._score = "---" assignment.exempted = True
[docs] def color_cells(self, styler, week_list, assignment_list): if week_list: week = week_list.pop() assignment = assignment_list.pop() # Apply the style to the current cell styler = styler.set_properties( subset=pd.IndexSlice[[week], [assignment]], **{"background-color": "yellow"}, ) # Recursive call return self.color_cells(styler, week_list, assignment_list) else: return styler
def _calculate_final_average(self): total_percentage = 1 df_ = self.compute_final_average() score_earned = 0 optional_weighted_assignments = [] final_weights = {} for assignment_type in self.assignment_type_list: if isinstance(assignment_type.weight, tuple): total_percentage -= assignment_type.weight[0] optional_weighted_assignments.append(assignment_type) final_weights[assignment_type.name] = list(assignment_type.weight) else: score_earned += assignment_type.weight * df_[assignment_type.name] non_optional_score = score_earned / total_percentage combinations = list(product(*final_weights.values())) final_scores_list = [] for weight_combo in combinations: score = 0 for i, assignment_type in enumerate(optional_weighted_assignments): score += weight_combo[i] * df_[assignment_type.name] final_scores_list.append( non_optional_score * (1 - sum(weight_combo)) + score ) self.final_grade = score_earned / total_percentage self.final_grade_final = max(*final_scores_list) self.weighted_average_grades = pd.concat( [ pd.DataFrame(self.final_grades), pd.DataFrame( {"Running Avg": [self.final_grade]}, index=["Weighted Average Grade"], ), pd.DataFrame( {"Running Avg": [self.final_grade_final]}, index=["Weighted Average Grade w Final"], ), pd.DataFrame( {"Running Avg": [self._get_letter_grade(self.final_grade_final)]}, index=["Letter Grade"], ), ] )
[docs] def update_weekly_table(self): self._update_weekly_table_nan() self._update_weekly_table_scores()
def _get_letter_grade(self, score): for low, high, grade in grade_ranges: if low <= score <= high: return grade return "Invalid Score" # TODO: populate with average scores calculated from the exempted def _update_weekly_table_scores(self): for assignment in self.graded_assignments: if assignment.weekly: self.weekly_grades_df_display.loc[ f"week{assignment.week}", assignment.name ] = assignment._score def _update_weekly_table_nan(self): """Updates the weekly grades table with the calculated scores.""" for assignment in self.graded_assignments: if assignment.weekly: self.weekly_grades_df.loc[f"week{assignment.week}", assignment.name] = ( assignment.score )
[docs] def update_global_exempted_assignments(self): """Updates the graded assignments with the globally exempted assignments. If assignment doesn't exist, pass.""" for assignment_type, week in self.globally_exempted_assignments: try: self.get_graded_assignment(week, assignment_type)[0].exempted = True self.get_graded_assignment(week, assignment_type)[0]._score = "---" except: # noqa: E722 pass
[docs] def build_assignments(self): """Generates a list of Assignment objects for each week, applying custom adjustments where needed.""" self.graded_assignments = [] weekly_assignments = self.get_weekly_assignments() for assignment_type in weekly_assignments: for week in range(1, self.max_week + 1): # Weeks start at 1 self.graded_assignment_constructor(assignment_type, week=week) non_weekly_assignments = self.get_non_weekly_assignments() for assignment_type in non_weekly_assignments: self.graded_assignment_constructor(assignment_type)
[docs] def graded_assignment_constructor(self, assignment_type: AssignmentType, **kwargs): """Constructs a graded assignment object and appends it to the graded_assignments list. Args: assignment_type (str): Type of assignment. Options: readings, lecture, practicequiz, quiz, homework, lab, labattendance, practicemidterm, midterm, practicefinal, final. """ custom_func = custom_grade_adjustments.get( (assignment_type.name, kwargs.get("week", None)), None ) filtered_assignments = self.get_assignment( kwargs.get("week", None), assignment_type.name ) new_assignment = Assignment( name=assignment_type.name, weekly=assignment_type.weekly, weight=assignment_type.weight, score=0, grade_adjustment_func=custom_func, # filters the submissions for an assignment and gets the last due date due_date=self.determine_due_date(filtered_assignments), max_score=self.get_max_score(filtered_assignments), **kwargs, ) self.graded_assignments.append(new_assignment)
[docs] def calculate_grades(self): """Calculates the grades for each student based on the graded assignments. If there are filtered assignments, the score is updated based on the submission. Otherwise, """ for assignment in self.graded_assignments: filtered_submission = self.filter_submissions( assignment.week, assignment.name ) if filtered_submission: for submission in filtered_submission: assignment.update_score(submission, student_name=self.student_name) # runs if there are no filtered submissions else: assignment.update_score(student_name=self.student_name)
[docs] def compute_final_average(self): """ Computes the final average by combining the running average from weekly assignments and the midterm/final exam scores. """ # Extract running average from the weekly table self.final_grades = self.weekly_grades_df.loc["Running Avg"] for assignment in self.graded_assignments: if not assignment.weekly: self.final_grades[f"{assignment.name}"] = assignment.score return self.final_grades
[docs] def filter_submissions(self, week_number, assignment_type): # Normalize the assignment type using aliases normalized_type = self.aliases.get( assignment_type.lower(), [assignment_type.lower()] ) if week_number: # Filter the assignments based on the week number and normalized assignment type filtered = [ assignment for assignment in self.student_subs if assignment["week_number"] == week_number and assignment["assignment_type"].lower() in normalized_type ] # If week_number is None, filter based on the normalized assignment type only else: # Filter the assignments based on the normalized assignment type filtered = [ assignment for assignment in self.student_subs if assignment["assignment_type"].lower() in normalized_type ] return filtered
[docs] def get_assignment(self, week_number, assignment_type): # Normalize the assignment type using aliases normalized_type = self.aliases.get( assignment_type.lower(), [assignment_type.lower()] ) # Filter the assignments based on the week number and normalized assignment type filtered = [ assignment for assignment in self.assignments if (assignment["week_number"] == week_number or week_number is None) and assignment["assignment_type"].lower() in normalized_type ] return filtered
[docs] def get_graded_assignment(self, week_number, assignment_type): return list( filter( lambda a: isinstance(a, Assignment) and a.name == assignment_type and (week_number is None or a.week == week_number), self.graded_assignments, ) )
[docs] def get_max_score(self, filtered_assignments): if not filtered_assignments: return None return max(filtered_assignments, key=lambda x: x["id"])["max_score"]
[docs] def determine_due_date(self, filtered_assignments): if not filtered_assignments: return None # Return None if the list is empty # Convert due_date strings to datetime objects and find the max max_due = max( filtered_assignments, key=lambda x: datetime.fromisoformat(x["due_date"].replace("Z", "+00:00")), ) return max_due["due_date"] # Return the max due date as a string
[docs] def get_non_weekly_assignments(self): """Get all weekly assignments from the assignment list configuration""" non_weekly_assignments = [ assignment for assignment in self.assignment_type_list if not assignment.weekly ] return non_weekly_assignments
[docs] def get_weekly_assignments(self): """Get all weekly assignments from the assignment list configuration""" weekly_assignments = [ assignment for assignment in self.assignment_type_list if assignment.weekly ] return weekly_assignments
[docs] def get_num_weeks(self): """Get the number of weeks in the course""" max_week_number = max(item["week_number"] for item in self.assignments) return max_week_number
[docs] def setup_grades_df(self): weekly_assignments = self.get_weekly_assignments() inds = [f"week{i + 1}" for i in range(self.max_week)] + ["Running Avg"] restruct_grades = { k.name: [0 for i in range(len(inds))] for k in weekly_assignments } new_weekly_grades = pd.DataFrame(restruct_grades, dtype=float) new_weekly_grades["inds"] = inds new_weekly_grades.set_index("inds", inplace=True) self.weekly_grades_df = new_weekly_grades self.weekly_grades_df_display = new_weekly_grades.copy().astype(str)
def _build_running_avg(self): """ Subfunction to compute and update the Running Avg row, handling NaNs. """ self.weekly_grades_df.loc["Running Avg"] = self.weekly_grades_df.drop( "Running Avg", errors="ignore" ).mean(axis=0, skipna=True) self.weekly_grades_df_display.loc["Running Avg"] = self.weekly_grades_df.drop( "Running Avg", errors="ignore" ).mean(axis=0, skipna=True)
[docs] def drop_lowest_n_for_types(self, n, assignments_=None): """ Exempts the lowest n assignments for each specified assignment type. If the lowest dropped score is from week 1, an additional lowest score is dropped. :param assignments_: List of assignment types (names) to process. :param n: Number of lowest scores to exempt per type. """ from collections import defaultdict import numpy as np # Group assignments by name assignment_groups = defaultdict(list) for assignment in self.graded_assignments: if assignments_ is None: if ( assignment.name in self.dropped_assignments and not assignment.exempted ): assignment_groups[assignment.name].append(assignment) else: if assignment.name in assignments_ and not assignment.exempted: assignment_groups[assignment.name].append(assignment) # Iterate over each specified assignment type and drop the lowest n scores for name, assignments in assignment_groups.items(): # Filter assignments that are not already exempted (NaN scores should not count) valid_assignments = [a for a in assignments if not np.isnan(a.score)] # Sort assignments by score in ascending order valid_assignments.sort(key=lambda a: a.score) # Exempt the lowest `n` assignments dropped = [] i = 0 j = 0 while i < n: valid_assignments[i + j].exempted = True if valid_assignments[i + j].week in self.optional_drop_week: j += 1 continue if ( name, valid_assignments[i + j].week, ) in self.optional_drop_assignments: j += 1 continue dropped.append(valid_assignments[i + j]) self.student_assignments_dropped.append(valid_assignments[i + j]) i += 1
[docs] def duplicate_scores(self): """Duplicate scores from one assignment to another""" for (week, assignment_type), ( duplicate_week, duplicate_assignment_type, ) in duplicated_scores: assignment = self.get_graded_assignment(week, assignment_type)[0] duplicate_assignment = self.get_graded_assignment( duplicate_week, duplicate_assignment_type )[0] duplicate_assignment.score = assignment.score duplicate_assignment._score = assignment._score duplicate_assignment.exempted = assignment.exempted