See Release Notes
Long Term Support Release
Differences Between: [Versions 401 and 403]
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 namespace qbank_statistics; 18 19 use core_question\statistics\questions\all_calculated_for_qubaid_condition; 20 use core_component; 21 22 /** 23 * Helper for statistics 24 * 25 * @package qbank_statistics 26 * @copyright 2021 Catalyst IT Australia Pty Ltd 27 * @author Nathan Nguyen <nathannguyen@catalyst-au.net> 28 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 29 */ 30 class helper { 31 32 /** 33 * @var float Threshold to determine 'Needs checking?' 34 */ 35 private const NEED_FOR_REVISION_LOWER_THRESHOLD = 30; 36 37 /** 38 * @var float Threshold to determine 'Needs checking?' 39 */ 40 private const NEED_FOR_REVISION_UPPER_THRESHOLD = 50; 41 42 /** 43 * For a list of questions find all the places, defined by (component, contextid) where there are attempts. 44 * 45 * @param int[] $questionids array of question ids that we are interested in. 46 * @return \stdClass[] list of objects with fields ->component and ->contextid. 47 */ 48 private static function get_all_places_where_questions_were_attempted(array $questionids): array { 49 global $DB; 50 51 [$questionidcondition, $params] = $DB->get_in_or_equal($questionids); 52 // The MIN(qu.id) is just to ensure that the rows have a unique key. 53 $places = $DB->get_records_sql(" 54 SELECT MIN(qu.id) AS somethingunique, qu.component, qu.contextid, " . 55 \context_helper::get_preload_record_columns_sql('ctx') . " 56 FROM {question_usages} qu 57 JOIN {question_attempts} qa ON qa.questionusageid = qu.id 58 JOIN {context} ctx ON ctx.id = qu.contextid 59 WHERE qa.questionid $questionidcondition 60 GROUP BY qu.component, qu.contextid, " . 61 implode(', ', array_keys(\context_helper::get_preload_record_columns('ctx'))) . " 62 ORDER BY qu.contextid ASC 63 ", $params); 64 65 // Strip out the unwanted ids. 66 $places = array_values($places); 67 foreach ($places as $place) { 68 unset($place->somethingunique); 69 \context_helper::preload_from_record($place); 70 } 71 72 return $places; 73 } 74 75 /** 76 * Load the question statistics for all the attempts belonging to a particular component in a particular context. 77 * 78 * @param string $component frankenstyle component name, e.g. 'mod_quiz'. 79 * @param \context $context the context to load the statistics for. 80 * @return all_calculated_for_qubaid_condition|null question statistics. 81 */ 82 private static function load_statistics_for_place(string $component, \context $context): ?all_calculated_for_qubaid_condition { 83 // This check is basically if (component_exists). 84 if (empty(core_component::get_component_directory($component))) { 85 return null; 86 } 87 88 if (!component_callback_exists($component, 'calculate_question_stats')) { 89 return null; 90 } 91 92 return component_callback($component, 'calculate_question_stats', [$context]); 93 } 94 95 /** 96 * Extract the value for one question and one type of statistic from a set of statistics. 97 * 98 * @param all_calculated_for_qubaid_condition $statistics the batch of statistics. 99 * @param int $questionid a question id. 100 * @param string $item ane of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'. 101 * @return float|null the required value. 102 */ 103 private static function extract_item_value(all_calculated_for_qubaid_condition $statistics, 104 int $questionid, string $item): ?float { 105 106 // Look in main questions. 107 foreach ($statistics->questionstats as $stats) { 108 if ($stats->questionid == $questionid && isset($stats->$item)) { 109 return $stats->$item; 110 } 111 } 112 113 // If not found, look in sub questions. 114 foreach ($statistics->subquestionstats as $stats) { 115 if ($stats->questionid == $questionid && isset($stats->$item)) { 116 return $stats->$item; 117 } 118 } 119 120 return null; 121 } 122 123 /** 124 * Calculate average for a stats item on a list of questions. 125 * 126 * @param int[] $questionids list of ids of the questions we are interested in. 127 * @param string $item ane of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'. 128 * @return array array keys are question ids and the corresponding values are the average values. 129 * Only questions for which there are data are included. 130 */ 131 private static function calculate_average_question_stats_item(array $questionids, string $item): array { 132 $places = self::get_all_places_where_questions_were_attempted($questionids); 133 134 $counts = []; 135 $sums = []; 136 137 foreach ($places as $place) { 138 $statistics = self::load_statistics_for_place($place->component, 139 \context::instance_by_id($place->contextid)); 140 if ($statistics === null) { 141 continue; 142 } 143 144 foreach ($questionids as $questionid) { 145 $value = self::extract_item_value($statistics, $questionid, $item); 146 if ($value === null) { 147 continue; 148 } 149 150 $counts[$questionid] = ($counts[$questionid] ?? 0) + 1; 151 $sums[$questionid] = ($sums[$questionid] ?? 0) + $value; 152 } 153 } 154 155 // Return null if there is no quizzes. 156 $averages = []; 157 foreach ($sums as $questionid => $sum) { 158 $averages[$questionid] = $sum / $counts[$questionid]; 159 } 160 return $averages; 161 } 162 163 /** 164 * Calculate average facility index 165 * 166 * @param int $questionid 167 * @return float|null 168 */ 169 public static function calculate_average_question_facility(int $questionid): ?float { 170 $averages = self::calculate_average_question_stats_item([$questionid], 'facility'); 171 return $averages[$questionid] ?? null; 172 } 173 174 /** 175 * Calculate average discriminative efficiency 176 * 177 * @param int $questionid question id 178 * @return float|null 179 */ 180 public static function calculate_average_question_discriminative_efficiency(int $questionid): ?float { 181 $averages = self::calculate_average_question_stats_item([$questionid], 'discriminativeefficiency'); 182 return $averages[$questionid] ?? null; 183 } 184 185 /** 186 * Calculate average discriminative efficiency 187 * 188 * @param int $questionid question id 189 * @return float|null 190 */ 191 public static function calculate_average_question_discrimination_index(int $questionid): ?float { 192 $averages = self::calculate_average_question_stats_item([$questionid], 'discriminationindex'); 193 return $averages[$questionid] ?? null; 194 } 195 196 /** 197 * Format a number to a localised percentage with specified decimal points. 198 * 199 * @param float|null $number The number being formatted 200 * @param bool $fraction An indicator for whether the number is a fraction or is already multiplied by 100 201 * @param int $decimals Sets the number of decimal points 202 * @return string 203 * @throws \coding_exception 204 */ 205 public static function format_percentage(?float $number, bool $fraction = true, int $decimals = 2): string { 206 if (is_null($number)) { 207 return get_string('na', 'qbank_statistics'); 208 } 209 $coefficient = $fraction ? 100 : 1; 210 return get_string('percents', 'moodle', format_float($number * $coefficient, $decimals)); 211 } 212 213 /** 214 * Format discrimination index (Needs checking?). 215 * 216 * @param float|null $value stats value 217 * @return array 218 */ 219 public static function format_discrimination_index(?float $value): array { 220 if (is_null($value)) { 221 $content = get_string('emptyvalue', 'qbank_statistics'); 222 $classes = ''; 223 } else if ($value < self::NEED_FOR_REVISION_LOWER_THRESHOLD) { 224 $content = get_string('verylikely', 'qbank_statistics'); 225 $classes = 'alert-danger'; 226 } else if ($value < self::NEED_FOR_REVISION_UPPER_THRESHOLD) { 227 $content = get_string('likely', 'qbank_statistics'); 228 $classes = 'alert-warning'; 229 } else { 230 $content = get_string('unlikely', 'qbank_statistics'); 231 $classes = 'alert-success'; 232 } 233 return [$content, $classes]; 234 } 235 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body