diff --git a/lib/grade_calculator.rb b/lib/grade_calculator.rb index e72e3eea5be..e6aaf735a69 100644 --- a/lib/grade_calculator.rb +++ b/lib/grade_calculator.rb @@ -51,7 +51,7 @@ class GradeCalculator end end - :private + private # The score ignoring unsubmitted assignments def calculate_current_score(user_id, submissions) @@ -67,92 +67,126 @@ class GradeCalculator @final_updates << "WHEN user_id=#{user_id} THEN #{score || "NULL"}" end - # Creates a hash for each assignment group with stats and the end score for each group + # returns information about assignments groups in the form: + # {1=> + # {:tally=>0.818181818181818, + # :submission_count=>2, + # :total_points=>110, + # :user_points=>90, + # :name=>"Assignments", + # :weighted_tally=>0, + # :group_weight=>0}, + # 5=> {...}} + # each group def create_group_sums(submissions, ignore_ungraded=true) + assignments_by_group_id = @assignments.group_by(&:assignment_group_id) + submissions_by_assignment_id = Hash[ + submissions.map { |s| [s.assignment_id, s] } + ] + group_sums = {} @groups.each do |group| - group_assignments = @assignments.select { |a| a.assignment_group_id == group.id } - assignment_submissions = [] - sums = {:name => group.name, - :total_points => 0, - :user_points => 0, - :group_weight => group.group_weight || 0, - :submission_count => 0} + assignments = assignments_by_group_id[group.id] || [] - # collect submissions for this user for all the assignments - # if an assignment is muted it will be treated as if there is no submission - group_assignments.each do |assignment| - submission = submissions.detect { |s| s.assignment_id == assignment.id } - submission = nil if assignment.muted - submission ||= OpenStruct.new(:assignment_id=>assignment.id, :score=>0) unless ignore_ungraded - assignment_submissions << {:assignment => assignment, :submission => submission} + group_submissions = assignments.map do |a| + s = submissions_by_assignment_id[a.id] + + # ignore submissions for muted assignments + s = nil if a.muted? + + { + :assignment => a, + :submission => s, + :score => s && s.score, + :total => a.points_possible + } end - - # Sort the submissions that have a grade by score (to easily drop lowest/highest grades) - if ignore_ungraded - sorted_assignment_submissions = assignment_submissions.select { |hash| hash[:submission] && hash[:submission].score } - else - sorted_assignment_submissions = assignment_submissions.select { |hash| hash[:submission] } - end - sorted_assignment_submissions = sorted_assignment_submissions.sort_by do |hash| - val = (((hash[:submission].score || 0)/ hash[:assignment].points_possible) rescue 999999) - val.to_f.finite? ? val : 999999 - end - - # Mark the assignments that aren't allowed to be dropped - if group.rules_hash[:never_drop] - sorted_assignment_submissions.each do |hash| - never_drop = group.rules_hash[:never_drop].include?(hash[:assignment].id) - hash[:never_drop] = true if never_drop - end - end - - # Flag the the lowest assignments to be dropped - low_drop_count = 0 - high_drop_count = 0 - total_scored = sorted_assignment_submissions.length - if group.rules_hash[:drop_lowest] - drop_total = group.rules_hash[:drop_lowest] || 0 - sorted_assignment_submissions.each do |hash| - if !hash[:drop] && !hash[:never_drop] && low_drop_count < drop_total && (low_drop_count + high_drop_count + 1) < total_scored - low_drop_count += 1 - hash[:drop] = true - end - end - end - - # Flag the highest assignments to be dropped - if group.rules_hash[:drop_highest] - drop_total = group.rules_hash[:drop_highest] || 0 - sorted_assignment_submissions.reverse.each do |hash| - if !hash[:drop] && !hash[:never_drop] && high_drop_count < drop_total && (low_drop_count + high_drop_count + 1) < total_scored - high_drop_count += 1 - hash[:drop] = true - end - end - end - - # If all submissions are marked to be dropped: don't drop the highest because we need at least one - if !sorted_assignment_submissions.empty? && sorted_assignment_submissions.all? { |hash| hash[:drop] } - sorted_assignment_submissions[-1][:drop] = false - end - - # Count the points from all the non-dropped submissions - sorted_assignment_submissions.select { |hash| !hash[:drop] }.each do |hash| - sums[:submission_count] += 1 - sums[:total_points] += hash[:assignment].points_possible || 0 - sums[:user_points] += (hash[:submission] && hash[:submission].score) || 0 - end - - # Calculate the tally for this group - sums[:group_weight] = group.group_weight || 0 - sums[:tally] = sums[:user_points].to_f / sums[:total_points].to_f - sums[:tally] = 0.0 unless sums[:tally].finite? - sums[:weighted_tally] = sums[:tally] * sums[:group_weight].to_f - group_sums[group.id] = sums + group_submissions.reject! { |s| s[:score].nil? } if ignore_ungraded + group_submissions.reject! { |s| s[:total].to_i.zero? } + group_submissions.each { |s| s[:score] ||= 0 } + + kept = drop_assignments(group_submissions, group.rules_hash) + + score, possible = kept.inject([0, 0]) { |(s_sum,p_sum),s| + [s_sum + s[:score], p_sum + s[:total]] + } + grade = score.to_f / possible + weight = group.group_weight.to_f + + group_sums[group.id] = { + :name => group.name, + :tally => grade, + :submission_count => kept.size, + :total_points => possible, + :user_points => score, + :group_weight => weight, + :weighted_tally => grade * weight, + } end group_sums end + + # see comments for dropAssignments in grade_calculator.coffee + def drop_assignments(submissions, rules) + drop_lowest = rules[:drop_lowest] || 0 + drop_highest = rules[:drop_highest] || 0 + never_drop_ids = rules[:never_drop] || [] + return submissions if drop_lowest.zero? && drop_highest.zero? + + if never_drop_ids.empty? + cant_drop = [] + else + cant_drop, submissions = submissions.partition { |s| + never_drop_ids.include? s[:assignment].id + } + end + + # fudge the drop rules if there aren't enough submissions + return cant_drop if submissions.empty? + drop_lowest = submissions.size - 1 if drop_lowest >= submissions.size + drop_highest = 0 if drop_lowest + drop_highest >= submissions.size + + totals = submissions.map { |s| s[:total] } + max_total = totals.max + + grades = submissions.map { |s| s[:score].to_f / s[:total] }.sort + q_low = grades.first + q_high = grades.last + q_mid = (q_low + q_high) / 2 + + keep_highest = submissions.size - drop_lowest + + x, kept = big_f(q_mid, submissions, keep_highest) + threshold = 1 / (2 * keep_highest * max_total**2) + until q_high - q_low < threshold + x < 0 ? + q_high = q_mid : + q_low = q_mid + q_mid = (q_low + q_high) / 2 + x, kept = big_f(q_mid, submissions, keep_highest) + end + + kept.shift(drop_highest) + kept += cant_drop + + kept + end + + # helper function for drop_assignments + # + # returns a new q value (which should eventually approach 0) and any + # submissions kept according to the given value of q + def big_f(q, submissions, keep) + kept = submissions.map { |s| + rated_score = s[:score] - q * s[:total] + [rated_score, s] + }.sort { |(a_rated_score,az), (b_rated_score,bz)| + b_rated_score <=> a_rated_score + }.first(keep) + + q_kept = kept.reduce(0) { |sum,(rated_score,z)| sum + rated_score } + [q_kept, kept.map(&:last)] + end # Calculates the final score from the sums of all the assignment groups def calculate_total_from_group_scores(group_sums, ignore_ungraded=true) diff --git a/spec/lib/grade_calculator_spec.rb b/spec/lib/grade_calculator_spec.rb index 7b49d917aee..f878438681c 100644 --- a/spec/lib/grade_calculator_spec.rb +++ b/spec/lib/grade_calculator_spec.rb @@ -364,7 +364,72 @@ describe GradeCalculator do @user.enrollments.first.computed_current_score.should eql(52.5) @user.enrollments.first.computed_final_score.should eql(43.6) end - + end + + # We should keep this in sync with GradeCalculatorSpec.coffee + context "GradeCalculatorSpec.coffee examples" do + before do + course_with_student + + @grades = [[100,100], [42,91], [14,55], [3,38], [nil,1000]] + @group = @course.assignment_groups.create! :name => 'group 1' + @assignments = @grades.map do |score,possible| + @course.assignments.create! :title => 'homework', + :points_possible => possible, + :assignment_group => @group + end + end + + def set_default_grades + @assignments.each_with_index do |a,i| + score = @grades[i].first + next unless score # don't grade nil submissions + a.grade_student @student, :grade => score + end + end + + def check_grades(current, final) + GradeCalculator.recompute_final_score(@student.id, @course.id) + @enrollment.reload + @enrollment.computed_current_score.should == current + @enrollment.computed_final_score.should == final + end + + it "should work without assignments or submissions" do + @group.assignments.clear + check_grades(nil, nil) + end + + it "should work without submissions" do + check_grades(nil, 0) + end + + it "should work with no drop rules" do + set_default_grades + check_grades(56.0, 12.4) + end + + it "should support drop_lowest" do + set_default_grades + @group.update_attribute(:rules, 'drop_lowest:1') + check_grades(63.4, 56.0) + + @group.update_attribute(:rules, 'drop_lowest:2') + check_grades(74.6, 63.4) + end + + it "should support drop_highest" do + set_default_grades + @group.update_attribute(:rules, 'drop_highest:1') + check_grades(32.1, 5.0) + end + + it "should support never_drop" do + set_default_grades + rules = "drop_lowest:1\nnever_drop:#{@assignments[3].id}" # 3/38 + @group.update_attribute(:rules, rules) + check_grades(63.3, 56.0) + end end end