canvas-lms/app/models/rollup_score.rb

187 lines
6.4 KiB
Ruby

# frozen_string_literal: true
#
# Copyright (C) 2015 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
class RollupScore
include RollupScoreAggregatorHelper
PRECISION = 2
attr_reader :outcome_results, :outcome, :score, :count, :title, :submitted_at, :hide_points
def initialize(outcome_results:, opts: {})
@outcome_results = outcome_results
@aggregate = opts[:aggregate_score]
@median = opts[:aggregate_stat] == "median"
@outcome = @outcome_results.first.learning_outcome
@count = @outcome_results.size
if opts[:points_possible].present?
@points_possible = opts[:points_possible]
@mastery_points = opts[:mastery_points]
else
@points_possible = @outcome.rubric_criterion[:points_possible]
@mastery_points = @outcome.rubric_criterion[:mastery_points]
end
if opts[:calculation_method].present?
@calculation_method = opts[:calculation_method]
@calculation_int = opts[:calculation_int]
else
@calculation_method = @outcome.calculation_method || "highest"
@calculation_int = @outcome.calculation_int
end
score_set = if @aggregate
@median ? median_aggregate_score : aggregate_score
else
calculate_results
end
@score = score_set[:score] if score_set
@hide_points = score_set[:results].all?(&:hide_points) if score_set
latest_result unless @aggregate
end
def new_decaying_average_calculation_ff_enabled?
return @outcome.context.root_account.feature_enabled?(:outcomes_new_decaying_average_calculation) if @outcome.context
LoadAccount.default_domain_root_account.feature_enabled?(:outcomes_new_decaying_average_calculation)
end
# TODO: This code should be removed once the FF is retire and DB is migrated
def adjust_calculation_method
if new_decaying_average_calculation_ff_enabled?
case @calculation_method
when "standard_decaying_average"
"decaying_average"
when "decaying_average", "weighted_average"
"weighted_average"
else
@calculation_method
end
else
case @calculation_method
when "standard_decaying_average", "decaying_average", "weighted_average"
"weighted_average"
else
@calculation_method
end
end
end
# TODO: do send(@calculation_method) instead of the case to streamline this more
def calculate_results
# decaying average is default for new outcomes
# TODO: This line should be removed once the FF is retire and DB is migrated
# and use @calculation_method instead of method
method = adjust_calculation_method
case method
when "decaying_average"
return nil if @outcome_results.empty?
standard_decaying_average
when "weighted_average"
return nil if @outcome_results.empty?
decaying_average_set
when "n_mastery"
return nil if @outcome_results.length < @calculation_int
n_mastery_set
when "latest"
latest_set = score_sets.first
{ score: latest_set[:score].round(PRECISION), results: [latest_set[:result]] }
when "highest"
highest_set = score_sets.max_by { |set| set[:score] }
{ score: highest_set[:score].round(PRECISION), results: [highest_set[:result]] }
when "average"
return nil if @outcome_results.empty?
average_set
end
end
def n_mastery_set
return unless @outcome.rubric_criterion
# mastery_points represents the cutoff score for which results
# will be considered towards mastery
tmp_score_sets = score_sets.compact.delete_if { |set| set[:score] < @mastery_points }
return nil if tmp_score_sets.length < @calculation_int
tmp_scores = tmp_score_sets.pluck(:score)
n_mastery_score = (tmp_scores.sum.to_f / tmp_scores.size).round(PRECISION)
{ score: n_mastery_score, results: tmp_score_sets.pluck(:result) }
end
def decaying_average_set
# The term "decaying average" can mean different things depending on the user.
# There are multiple, reasonable, accurate interpretations. We have chosen
# to go with one that is more mathematically a "weighted average", but is
# typically what is meant when a "decaying average" is wanted. A true
# decaying average may be added in the future.
# default grading method with weight of 65 if none selected.
weight = @calculation_int || 65
tmp_score_sets = score_sets
latest = tmp_score_sets.pop
if tmp_score_sets.empty?
return { score: latest[:score].round(PRECISION), results: [latest[:result]] }
end
tmp_scores = tmp_score_sets.pluck(:score)
latest_weighted = latest[:score] * (0.01 * weight)
older_avg_weighted = (tmp_scores.sum / tmp_scores.length) * (0.01 * (100 - weight))
decaying_avg_score = (latest_weighted + older_avg_weighted).round(PRECISION)
{ score: decaying_avg_score, results: tmp_score_sets.pluck(:result).push(latest[:result]) }
end
def standard_decaying_average
# default decay_rate is 65 if none selected.
decay_rate = @calculation_int || 65
remaining_weight = 100 - decay_rate
decay_avg = nil
tmp_scores = score_sets.pluck(:score)
results = score_sets.pluck(:result)
# return if single assignment score
if tmp_scores.size == 1
decay_avg = tmp_scores[0].round(PRECISION)
return { score: decay_avg, results: }
end
tmp_scores.each_cons(2) do |score|
decay_avg = if decay_avg.nil?
(score[0] * (0.01 * remaining_weight)) + (score[1] * (0.01 * decay_rate))
else
(decay_avg * (0.01 * remaining_weight)) + (score[1] * (0.01 * decay_rate))
end
end
{ score: decay_avg.round(PRECISION), results: }
end
def average_set
tmp_scores = score_sets.pluck(:score)
average_score = (tmp_scores.sum.to_f / tmp_scores.size).round(PRECISION)
{ score: average_score, results: score_sets.pluck(:result) }
end
end