MDL-43338 quiz statistics : refactoring question statistics code
authorJames Pratt <me@jamiep.org>
Wed, 11 Dec 2013 09:59:27 +0000 (16:59 +0700)
committerJames Pratt <me@jamiep.org>
Fri, 7 Feb 2014 06:35:20 +0000 (13:35 +0700)
to improve readability and maintainability.

mod/quiz/report/statistics/report.php
mod/quiz/report/statistics/statistics_table.php
mod/quiz/report/statistics/tests/statistics_test.php
mod/quiz/report/statistics/tests/stats_from_steps_walkthrough_test.php
question/classes/statistics/questions/all_calculated_for_qubaid_condition.php [new file with mode: 0644]
question/classes/statistics/questions/calculated.php
question/classes/statistics/questions/calculated_for_subquestion.php
question/classes/statistics/questions/calculator.php

index 148ea39..a0101c5 100644 (file)
@@ -38,8 +38,6 @@ require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');
  */
 class quiz_statistics_report extends quiz_default_report {
 
-    const SUBQ_AND_VARIANT_ROW_LIMIT = 10;
-
     /**
      * @var context_module
      */
@@ -145,13 +143,12 @@ class quiz_statistics_report extends quiz_default_report {
         if (!$nostudentsingroup) {
             // Get the data to be displayed.
             $progress = $this->get_progress_trace_instance();
-            list($quizstats, $questionstats, $subquestionstats) =
+            list($quizstats, $questionstats) =
                 $this->get_all_stats_and_analysis($quiz, $whichattempts, $groupstudents, $questions, $progress);
         } else {
             // Or create empty stats containers.
             $quizstats = new \quiz_statistics\calculated($whichattempts);
-            $questionstats = array();
-            $subquestionstats = array();
+            $questionstats = new \core_question\statistics\questions\all_calculated_for_qubaid_condition();
         }
 
         // Set up the table, if there is data.
@@ -173,6 +170,10 @@ class quiz_statistics_report extends quiz_default_report {
                 echo $OUTPUT->notification(get_string('noattempts', 'quiz'));
             }
 
+            foreach($questionstats->any_error_messages() as $errormessage) {
+                echo $OUTPUT->notification($errormessage);
+            }
+
             // Print display options form.
             $mform->display();
         }
@@ -183,7 +184,7 @@ class quiz_statistics_report extends quiz_default_report {
             $this->download_quiz_info_table($quizinfo);
 
             if ($quizstats->s()) {
-                $this->output_quiz_structure_analysis_table($quizstats->s(), $questionstats, $subquestionstats);
+                $this->output_quiz_structure_analysis_table($questionstats);
 
                 if ($this->table->is_downloading() == 'xhtml' && $quizstats->s() != 0) {
                     $this->output_statistics_graph($quiz->id, $currentgroup, $whichattempts);
@@ -193,13 +194,14 @@ class quiz_statistics_report extends quiz_default_report {
                     if (question_bank::get_qtype(
                             $question->qtype, false)->can_analyse_responses()) {
                         $this->output_individual_question_response_analysis(
-                                $question, $questionstats[$slot]->s, $reporturl, $qubaids);
-
-                    } else if (!empty($questionstats[$slot]->subquestions)) {
-                        $subitemstodisplay = explode(',', $questionstats[$slot]->subquestions);
-                        foreach ($subitemstodisplay as $subitemid) {
-                            $this->output_individual_question_response_analysis(
-                                $subquestionstats[$subitemid]->question, $subquestionstats[$subitemid]->s, $reporturl, $qubaids);
+                                $question, $questionstats->for_slot($slot)->s, $reporturl, $qubaids);
+
+                    } else if ($subqids = $questionstats->for_slot($slot)->get_sub_question_ids()) {
+                        foreach ($subqids as $subqid) {
+                            $this->output_individual_question_response_analysis($questionstats->for_subq($subqid)->question,
+                                                                                $questionstats->for_subq($subqid)->s,
+                                                                                $reporturl,
+                                                                                $qubaids);
                         }
                     }
                 }
@@ -213,8 +215,11 @@ class quiz_statistics_report extends quiz_default_report {
                 print_error('questiondoesnotexist', 'question');
             }
 
-            $this->output_individual_question_data($quiz, $questionstats[$slot]);
-            $this->output_individual_question_response_analysis($questions[$slot], $questionstats[$slot]->s, $reporturl, $qubaids);
+            $this->output_individual_question_data($quiz, $questionstats->for_slot($slot));
+            $this->output_individual_question_response_analysis($questions[$slot],
+                                                                $questionstats->for_slot($slot)->s,
+                                                                $reporturl,
+                                                                $qubaids);
 
             // Back to overview link.
             echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
@@ -223,13 +228,15 @@ class quiz_statistics_report extends quiz_default_report {
 
         } else if ($qid) {
             // Report on an individual sub-question indexed questionid.
-            if (!isset($subquestionstats[$qid])) {
+            if (is_null($questionstats->for_subq($qid))) {
                 print_error('questiondoesnotexist', 'question');
             }
 
-            $this->output_individual_question_data($quiz, $subquestionstats[$qid]);
-            $this->output_individual_question_response_analysis($subquestionstats[$qid]->question,
-                                                                $subquestionstats[$qid]->s, $reporturl, $qubaids);
+            $this->output_individual_question_data($quiz, $questionstats->for_subq($qid));
+            $this->output_individual_question_response_analysis($questionstats->for_subq($qid)->question,
+                                                                $questionstats->for_subq($qid)->s,
+                                                                $reporturl,
+                                                                $qubaids);
 
             // Back to overview link.
             echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
@@ -240,7 +247,9 @@ class quiz_statistics_report extends quiz_default_report {
             // Downloading overview report.
             $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
             $this->download_quiz_info_table($quizinfo);
-            $this->output_quiz_structure_analysis_table($quizstats->s(), $questionstats, $subquestionstats);
+            if ($quizstats->s()) {
+                $this->output_quiz_structure_analysis_table($questionstats);
+            }
             $this->table->finish_output();
 
         } else {
@@ -252,7 +261,7 @@ class quiz_statistics_report extends quiz_default_report {
             echo $this->output_quiz_info_table($quizinfo);
             if ($quizstats->s()) {
                 echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'), 3);
-                $this->output_quiz_structure_analysis_table($quizstats->s(), $questionstats, $subquestionstats);
+                $this->output_quiz_structure_analysis_table($questionstats);
                 $this->output_statistics_graph($quiz->id, $currentgroup, $whichattempts);
             }
         }
@@ -402,100 +411,25 @@ class quiz_statistics_report extends quiz_default_report {
 
     /**
      * Output the table that lists all the questions in the quiz with their statistics.
-     * @param int $s number of attempts.
-     * @param \core_question\statistics\questions\calculated[] $questionstats the stats for the main questions in the quiz.
-     * @param \core_question\statistics\questions\calculated_for_subquestion[] $subquestionstats the stats of any random questions.
+     * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats the stats for all questions in
+     *                                                                                               the quiz including subqs and
+     *                                                                                               variants.
      */
-    protected function output_quiz_structure_analysis_table($s, $questionstats, $subquestionstats) {
-        if (!$s) {
-            return;
-        }
-
-        foreach ($questionstats as $questionstat) {
+    protected function output_quiz_structure_analysis_table($questionstats) {
+        foreach ($questionstats->get_all_slots() as $slot) {
             // Output the data for these question statistics.
-            $this->table->add_data_keyed($this->table->format_row($questionstat));
-            if (count($questionstat->variantstats) > 1) {
-                if (count($questionstat->variantstats) > static::SUBQ_AND_VARIANT_ROW_LIMIT) {
-                    $statstoadd = $this->find_min_median_and_max_facility_stats_objects($questionstat->variantstats);
-                } else {
-                    ksort($questionstat->variantstats);
-                    $statstoadd = $questionstat->variantstats;
-                }
-                $this->add_array_of_rows_to_table($statstoadd);
-            }
+            $this->table->add_data_keyed($this->table->format_row($questionstats->for_slot($slot)));
 
-            if (empty($questionstat->subquestions)) {
-                continue;
-            }
-
-            // And its sub-questions, if it has any.
-            $subitemstodisplay = explode(',', $questionstat->subquestions);
-
-            // We need to get all variants out of sub-questions to count them and possibly find min, median and max.
-            $displayorder = 1;
-            $subqvariants = array();
-            foreach ($subitemstodisplay as $subitemid) {
-                if (count($subquestionstats[$subitemid]->variantstats) > 1) {
-                    ksort($subquestionstats[$subitemid]->variantstats);
-                    foreach ($subquestionstats[$subitemid]->variantstats as $variantstat) {
-                        $variantstat->subqdisplayorder = $displayorder;
-                        $variantstat->question->number = $questionstat->question->number;
-                        $subqvariants[] = $variantstat;
-                    }
-                }
-                $displayorder++;
-            }
-            if (count($subqvariants) > static::SUBQ_AND_VARIANT_ROW_LIMIT) {
-                // Too many variants from randomly selected questions.
-                $toadd = $this->find_min_median_and_max_facility_stats_objects($subqvariants);
-                $this->add_array_of_rows_to_table($toadd);
-            } else if (count($subitemstodisplay) > static::SUBQ_AND_VARIANT_ROW_LIMIT) {
-                // Too many randomly selected questions.
-                $toadd = $this->find_min_median_and_max_facility_stats_objects($subitemstodisplay);
-                $this->add_array_of_rows_to_table($toadd);
-            } else {
-                foreach ($subitemstodisplay as $subitemid) {
-                    $subquestionstats[$subitemid]->maxmark = $questionstat->maxmark;
-                    $subquestionstats[$subitemid]->subqdisplayorder = $displayorder;
-                    $subquestionstats[$subitemid]->question->number = $questionstat->question->number;
-                    $this->table->add_data_keyed($this->table->format_row($subquestionstats[$subitemid]));
-                    if (count($subquestionstats[$subitemid]->variantstats) > 1) {
-                        ksort($subquestionstats[$subitemid]->variantstats);
-                        foreach ($subquestionstats[$subitemid]->variantstats as $variantstat) {
-                            $this->table->add_data_keyed($this->table->format_row($variantstat));
-                        }
-                    }
-                }
-            }
+            $limitvariants = !$this->table->is_downloading();
+            $this->add_array_of_rows_to_table($questionstats->all_subq_and_variant_stats_for_slot($slot, $limitvariants));
 
         }
 
         $this->table->finish_output(!$this->table->is_downloading());
     }
 
-    protected function find_min_median_and_max_facility_stats_objects($questionstats) {
-        $facilities = array();
-        foreach ($questionstats as $key => $questionstat) {
-            $facilities[$key] = (float)$questionstat->facility;
-        }
-        asort($facilities);
-        $facilitykeys = array_keys($facilities);
-        $keyformin = $facilitykeys[0];
-        $keyformedian = $facilitykeys[(int)(round(count($facilitykeys) / 2)-1)];
-        $keyformax = $facilitykeys[count($facilitykeys) - 1];
-        $toreturn = array();
-        foreach (array($keyformin => 'minimumfacility',
-                       $keyformedian => 'medianfacility',
-                       $keyformax => 'maximumfacility') as $key => $stringid) {
-            $questionstats[$key]->minmedianmaxnotice = get_string($stringid, 'quiz_statistics');
-            $toreturn[] = $questionstats[$key];
-        }
-        return $toreturn;
-    }
-
-
     /**
-     * @param \core_question\statistics\questions\calculator $statstoadd
+     * @param \core_question\statistics\questions\calculated[] $statstoadd
      */
     protected function add_array_of_rows_to_table($statstoadd) {
         foreach ($statstoadd as $stattoadd) {
@@ -580,10 +514,9 @@ class quiz_statistics_report extends quiz_default_report {
      *                                   we calculate stats based on which attempts would affect the grade for each student.
      * @param array  $groupstudents      students in this group.
      * @param array  $questions          full question data.
-     * @return array with 4 elements:
-     *     - $quizstats The statistics for overall attempt scores.
-     *     - $questionstats array of \core_question\statistics\questions\calculated objects keyed by slot.
-     *     - $subquestionstats array of \core_question\statistics\questions\calculated_for_subquestion objects keyed by question id.
+     * @param \core\progress\base|null   $progress
+     * @return array with 2 elements:    - $quizstats The statistics for overall attempt scores.
+     *                                   - $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition
      */
     public function get_all_stats_and_analysis($quiz, $whichattempts, $groupstudents, $questions, $progress = null) {
 
@@ -602,23 +535,24 @@ class quiz_statistics_report extends quiz_default_report {
             $progress->start_progress('', 3);
 
             // Recalculate now.
-            list($questionstats, $subquestionstats) = $qcalc->calculate($qubaids);
+            $questionstats = $qcalc->calculate($qubaids);
             $progress->progress(1);
 
             $quizstats = $quizcalc->calculate($quiz->id, $whichattempts, $groupstudents, count($questions),
                                               $qcalc->get_sum_of_mark_variance());
             $progress->progress(2);
             if ($quizstats->s()) {
-                $this->analyse_responses_for_all_questions_and_subquestions($qubaids, $questions, $subquestionstats, $progress);
+                $subquestions = $questionstats->get_sub_questions();
+                $this->analyse_responses_for_all_questions_and_subquestions($qubaids, $questions, $subquestions, $progress);
             }
             $progress->progress(3);
             $progress->end_progress();
         } else {
             $quizstats = $quizcalc->get_cached($qubaids);
-            list($questionstats, $subquestionstats) = $qcalc->get_cached($qubaids);
+            $questionstats = $qcalc->get_cached($qubaids);
         }
 
-        return array($quizstats, $questionstats, $subquestionstats);
+        return array($quizstats, $questionstats);
     }
 
     /**
@@ -638,7 +572,7 @@ class quiz_statistics_report extends quiz_default_report {
         return $this->progress;
     }
 
-    protected function analyse_responses_for_all_questions_and_subquestions($qubaids, $questions, $subquestionstats,
+    protected function analyse_responses_for_all_questions_and_subquestions($qubaids, $questions, $subquestions,
                                                                             $progress = null) {
 
         if ($progress === null) {
@@ -646,7 +580,7 @@ class quiz_statistics_report extends quiz_default_report {
         }
 
         // Starting response analysis tasks.
-        $progress->start_progress('', count($questions) + count($subquestionstats));
+        $progress->start_progress('', count($questions) + count($subquestions));
 
         // Starting response analysis of main questions.
         $progress->start_progress('', count($questions), count($questions));
@@ -667,19 +601,19 @@ class quiz_statistics_report extends quiz_default_report {
         $progress->end_progress();
 
         // Starting response analysis of sub-questions.
-        $countsubquestions = count($subquestionstats);
+        $countsubquestions = count($subquestions);
         $progress->start_progress('', $countsubquestions, $countsubquestions);
         $donecount = 1;
-        foreach ($subquestionstats as $subquestionstat) {
+        foreach ($subquestions as $subquestion) {
             $progress->progress($donecount);
             $donecount++;
-            if (!question_bank::get_qtype($subquestionstat->question->qtype, false)->can_analyse_responses() ||
-                    isset($done[$subquestionstat->question->id])) {
+            if (!question_bank::get_qtype($subquestion->qtype, false)->can_analyse_responses() ||
+                    isset($done[$subquestion->id])) {
                 continue;
             }
-            $done[$subquestionstat->question->id] = 1;
+            $done[$subquestion->id] = 1;
 
-            $responesstats = new \core_question\statistics\responses\analyser($subquestionstat->question);
+            $responesstats = new \core_question\statistics\responses\analyser($subquestion);
             $responesstats->calculate($qubaids);
         }
         // Finished sub-question tasks.
index 1449616..45bbcc6 100644 (file)
@@ -28,7 +28,9 @@ require_once($CFG->libdir.'/tablelib.php');
 
 /**
  * This table has one row for each question in the quiz, with sub-rows when
- * random questions appear. There are columns for the various statistics.
+ * random questions and variants appear.
+ *
+ * There are columns for the various item and position statistics.
  *
  * @copyright 2008 Jamie Pratt
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -136,6 +138,9 @@ class quiz_statistics_table extends flexible_table {
      * @return string contents of this table cell.
      */
     protected function col_number($questionstat) {
+        if (!isset($questionstat->question->number)) {
+            return '';
+        }
         $number = $questionstat->question->number;
 
         if (isset($questionstat->subqdisplayorder)) {
@@ -214,7 +219,7 @@ class quiz_statistics_table extends flexible_table {
         }
 
         if (!empty($questionstat->minmedianmaxnotice)) {
-            $name = $questionstat->minmedianmaxnotice . '<br />' . $name;
+            $name = get_string($questionstat->minmedianmaxnotice, 'quiz_statistics') . '<br />' . $name;
         }
 
         return $name;
index 6ba5284..3f5e090 100644 (file)
@@ -30,6 +30,18 @@ require_once($CFG->libdir . '/questionlib.php');
 require_once($CFG->dirroot . '/mod/quiz/locallib.php');
 require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
 
+class testable_all_calculated_for_qubaid_condition extends \core_question\statistics\questions\all_calculated_for_qubaid_condition {
+
+    /**
+     * Disabling caching in tests so we are always sure to force the calculation of stats right then and there.
+     *
+     * @param qubaid_condition $qubaids
+     */
+    public function cache($qubaids) {
+
+    }
+}
+
 /**
  * Test helper subclass of question_statistics
  *
@@ -43,6 +55,8 @@ class testable_question_statistics extends \core_question\statistics\questions\c
      */
     protected $lateststeps;
 
+    protected $statscollectionclassname = 'testable_all_calculated_for_qubaid_condition';
+
     public function set_step_data($states) {
         $this->lateststeps = $states;
     }
@@ -86,7 +100,7 @@ class testable_question_statistics extends \core_question\statistics\questions\c
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class quiz_statistics_question_stats_testcase extends basic_testcase {
-    /** @var qstats object created to test class. */
+    /** @var testable_all_calculated_for_qubaid_condition object created to test class. */
     protected $qstats;
 
     public function test_qstats() {
@@ -99,7 +113,7 @@ class quiz_statistics_question_stats_testcase extends basic_testcase {
         $questions = $this->get_records_from_csv(__DIR__.'/fixtures/mdl_question.csv');
         $calculator = new testable_question_statistics($questions);
         $calculator->set_step_data($steps);
-        list($this->qstats, ) = $calculator->calculate(null);
+        $this->qstats = $calculator->calculate(null);
 
         // Values expected are taken from contrib/tools/quiz_tools/stats.xls.
         $facility = array(0, 0, 0, 0, null, null, null, 41.19318182, 81.36363636,
@@ -127,13 +141,12 @@ class quiz_statistics_question_stats_testcase extends basic_testcase {
     }
 
     public function qstats_q_fields($fieldname, $values, $multiplier=1) {
-        foreach ($this->qstats as $qstat) {
+        foreach ($this->qstats->get_all_slots() as $slot) {
             $value = array_shift($values);
             if ($value !== null) {
-                $this->assertEquals($value, $qstat->{$fieldname} * $multiplier,
-                    '', 1E-6);
+                $this->assertEquals($value, $this->qstats->for_slot($slot)->{$fieldname} * $multiplier, '', 1E-6);
             } else {
-                $this->assertEquals($value, $qstat->{$fieldname} * $multiplier);
+                $this->assertEquals($value, $this->qstats->for_slot($slot)->{$fieldname} * $multiplier);
             }
         }
     }
index 171aeba..b9d8306 100644 (file)
@@ -80,7 +80,7 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth
         $whichattempts = QUIZ_GRADEAVERAGE;
         $groupstudents = array();
         $questions = $this->report->load_and_initialise_questions_for_calculations($this->quiz);
-        list($quizstats, $questionstats, $subquestionstats) =
+        list($quizstats, $questionstats) =
                         $this->report->get_all_stats_and_analysis($this->quiz, $whichattempts, $groupstudents, $questions);
 
         $qubaids = quiz_statistics_qubaids_condition($this->quiz->id, $groupstudents, $whichattempts);
@@ -102,11 +102,9 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth
             $this->assertTimeCurrent($responesstats->get_last_analysed_time($qubaids));
         }
 
-        // These quiz stats and the question stats found in qstats00.csv were calculated independently in spreadsheets which are
+        // These quiz stats and the question stats found in qstats00.csv were calculated independently in spreadsheet which is
         // available in open document or excel format here :
         // https://github.com/jamiepratt/moodle-quiz-tools/tree/master/statsspreadsheet
-
-        // These quiz stats and the position stats here are calculated in stats.xls and stats.ods available, see above github URL.
         $quizstatsexpected = array(
             'median' => 4.5,
             'firstattemptsavg' => 4.617333332,
@@ -129,8 +127,7 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth
             $slotqstats = $csvdata['qstats']->getRow($rowno);
             foreach ($slotqstats as $statname => $slotqstat) {
                 if ($statname !== 'slot') {
-                    $this->assert_stat_equals($questionstats, $subquestionstats, $slotqstats['slot'],
-                                              null, null, $statname, (float)$slotqstat);
+                    $this->assert_stat_equals($questionstats, $slotqstats['slot'], null, null, $statname, (float)$slotqstat);
                 }
             }
         }
@@ -146,7 +143,7 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth
                           'slot' => null,
                           'subquestion' => true);
         foreach ($itemstats as $statname => $expected) {
-            $this->assert_stat_equals($questionstats, $subquestionstats, 1, null, 'numerical', $statname, $expected);
+            $this->assert_stat_equals($questionstats, 1, null, 'numerical', $statname, $expected);
         }
 
 
@@ -176,7 +173,7 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth
                                                     'subquestion' => false));
         foreach ($statsforslot2variants as $variant => $stats) {
              foreach ($stats as $statname => $expected) {
-                 $this->assert_stat_equals($questionstats, $subquestionstats, 2, $variant, null, $statname, $expected);
+                 $this->assert_stat_equals($questionstats, 2, $variant, null, $statname, $expected);
              }
         }
     }
@@ -184,22 +181,21 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth
     /**
      * Check that the stat is as expected within a reasonable tolerance.
      *
-     * @param \core_question\statistics\questions\calculated[] $questionstats
-     * @param \core_question\statistics\questions\calculated_for_subquestion[] $subquestionstats
+     * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats
      * @param int                                              $slot
      * @param int|null                                         $variant if null then not a variant stat.
      * @param string|null                                      $subqname if null then not an item stat.
      * @param string                                           $statname
      * @param float                                            $expected
      */
-    protected function assert_stat_equals($questionstats, $subquestionstats, $slot, $variant, $subqname, $statname, $expected) {
+    protected function assert_stat_equals($questionstats, $slot, $variant, $subqname, $statname, $expected) {
 
         if ($variant === null && $subqname === null) {
-            $actual = $questionstats[$slot]->{$statname};
+            $actual = $questionstats->for_slot($slot)->{$statname};
         } else if ($subqname !== null) {
-            $actual = $subquestionstats[$this->randqids[$slot][$subqname]]->{$statname};
+            $actual = $questionstats->for_subq($this->randqids[$slot][$subqname])->{$statname};
         } else {
-            $actual = $questionstats[$slot]->variantstats[$variant]->{$statname};
+            $actual = $questionstats->for_slot($slot, $variant)->{$statname};
         }
         if (is_bool($expected) || is_string($expected)) {
             $this->assertEquals($expected, $actual, "$statname for slot $slot");
diff --git a/question/classes/statistics/questions/all_calculated_for_qubaid_condition.php b/question/classes/statistics/questions/all_calculated_for_qubaid_condition.php
new file mode 100644 (file)
index 0000000..4dca155
--- /dev/null
@@ -0,0 +1,445 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A collection of all the question statistics calculated for an activity instance ie. the stats calculated for slots and
+ * sub-questions and variants of those questions.
+ *
+ * @package    core_question
+ * @copyright  2013 The Open University
+ * @author     James Pratt me@jamiep.org
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_question\statistics\questions;
+
+/**
+ * A collection of all the question statistics calculated for an activity instance.
+ *
+ * @package core_question
+ */
+class all_calculated_for_qubaid_condition {
+
+    /**
+     * The limit of rows of sub-question and variants rows to display on main page of report before switching to showing min,
+     * median and max variants.
+     */
+    const SUBQ_AND_VARIANT_ROW_LIMIT = 10;
+
+    /**
+     * @var object[]
+     */
+    public $subquestions;
+
+    /**
+     * Holds slot (position) stats and stats for variants of questions in slots.
+     *
+     * @var calculated[]
+     */
+    public $questionstats = array();
+
+    /**
+     * Holds sub-question stats and stats for variants of subqs.
+     *
+     * @var calculated_for_subquestion[]
+     */
+    public $subquestionstats = array();
+
+    /**
+     * Set up a calculated_for_subquestion instance ready to store a randomly selected question's stats.
+     *
+     * @param object     $step
+     * @param int|null   $variant Is this to keep track of a variant's stats? If so what is the variant, if not null.
+     */
+    public function initialise_for_subq($step, $variant = null) {
+        $newsubqstat =  new calculated_for_subquestion($step, $variant);
+        if ($variant === null) {
+            $this->subquestionstats[$step->questionid] = $newsubqstat;
+        } else {
+            $this->subquestionstats[$step->questionid]->variantstats[$variant] = $newsubqstat;
+        }
+    }
+
+    /**
+     * Set up a calculated instance ready to store a slot question's stats.
+     *
+     * @param int      $slot
+     * @param object   $question
+     * @param int|null $variant Is this to keep track of a variant's stats? If so what is the variant, if not null.
+     */
+    public function initialise_for_slot($slot, $question, $variant = null) {
+        $newqstat = new calculated($question, $slot, $variant);
+        if ($variant === null) {
+            $this->questionstats[$slot] = $newqstat;
+        } else {
+            $this->questionstats[$slot]->variantstats[$variant] = $newqstat;
+        }
+    }
+
+    /**
+     * Reference for a item stats instance for a questionid and optional variant no.
+     *
+     * @param      $questionid
+     * @param int|null $variant if not null then we want the object to store a variant of a sub-question's stats.
+     * @return calculated_for_subquestion|null null if the stats object does not yet exist.
+     */
+    public function for_subq($questionid, $variant = null) {
+        if ($variant === null) {
+            if (!isset($this->subquestionstats[$questionid])) {
+                return null;
+            } else {
+                return $this->subquestionstats[$questionid];
+            }
+        } else {
+            if (!isset($this->subquestionstats[$questionid]->variantstats[$variant])) {
+                return null;
+            } else {
+                return $this->subquestionstats[$questionid]->variantstats[$variant];
+            }
+        }
+    }
+
+    /**
+     * ids of all randomly selected question for all slots.
+     *
+     * @return int[] An array of all sub-question ids.
+     */
+    public function get_all_subq_ids() {
+        return array_keys($this->subquestionstats);
+    }
+
+    /**
+     * All slots nos that stats have been calculated for.
+     *
+     * @return int[] An array of all slot nos.
+     */
+    public function get_all_slots() {
+        return array_keys($this->questionstats);
+    }
+
+    /**
+     * Array of variants of one randomly selected question that have appeared in the attempt data.
+     *
+     * @param $questionid
+     * @return int[]
+     */
+    public function get_variants_for_subq($questionid) {
+        if (count($this->subquestionstats[$questionid]->variantstats) > 1) {
+            return array_keys($this->subquestionstats[$questionid]->variantstats);
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Array of variants that have appeared in the attempt data for a question in one slot.
+     *
+     * @param $slot
+     * @return int[]
+     */
+    public function get_variants_for_slot($slot) {
+        if (count($this->questionstats[$slot]->variantstats) > 1) {
+            return array_keys($this->questionstats[$slot]->variantstats);
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Reference to position stats instance for a slot and optional variant no.
+     *
+     * @param      $slot
+     * @param null $variant if provided then we want the object which stores a variant of a position's stats.
+     * @return calculated|null
+     */
+    public function for_slot($slot, $variant = null) {
+        if ($variant === null) {
+            if (!isset($this->questionstats[$slot])) {
+                return null;
+            } else {
+                return $this->questionstats[$slot];
+            }
+        } else {
+            if (!isset($this->questionstats[$slot]->variantstats[$variant])) {
+                return null;
+            } else {
+                return $this->questionstats[$slot]->variantstats[$variant];
+            }
+        }
+    }
+
+    /**
+     * Load cached statistics from the database.
+     *
+     * @param $qubaids \qubaid_condition
+     */
+    public function get_cached($qubaids) {
+        global $DB;
+
+        $timemodified = time() - self::TIME_TO_CACHE;
+        $questionstatrecs = $DB->get_records_select('question_statistics', 'hashcode = ? AND timemodified > ?',
+                                                    array($qubaids->get_hash_code(), $timemodified));
+
+        $questionids = array();
+        foreach ($questionstatrecs as $fromdb) {
+            if (is_null($fromdb->variant) && !$fromdb->slot) {
+                $questionids[] = $fromdb->questionid;
+            }
+        }
+        $this->subquestions = question_load_questions($questionids);
+        foreach ($questionstatrecs as $fromdb) {
+            if (is_null($fromdb->variant)) {
+                if ($fromdb->slot) {
+                    $this->questionstats[$fromdb->slot]->populate_from_record($fromdb);
+                    // Array created in constructor and populated from question.
+                } else {
+                    $this->subquestionstats[$fromdb->questionid] = new calculated_for_subquestion();
+                    $this->subquestionstats[$fromdb->questionid]->populate_from_record($fromdb);
+                    $this->subquestionstats[$fromdb->questionid]->question = $this->subquestions[$fromdb->questionid];
+                }
+            }
+        }
+        // Add cached variant stats to data structure.
+        foreach ($questionstatrecs as $fromdb) {
+            if (!is_null($fromdb->variant)) {
+                if ($fromdb->slot) {
+                    $newcalcinstance = new calculated();
+                    $this->questionstats[$fromdb->slot]->variantstats[$fromdb->variant] = $newcalcinstance;
+                    $newcalcinstance->question = $this->questionstats[$fromdb->slot]->question;
+                } else {
+                    $newcalcinstance = new calculated_for_subquestion();
+                    $this->subquestionstats[$fromdb->questionid]->variantstats[$fromdb->variant] = $newcalcinstance;
+                    $newcalcinstance->question = $this->subquestions[$fromdb->questionid];
+                }
+                $newcalcinstance->populate_from_record($fromdb);
+            }
+        }
+    }
+
+    /**
+     * Find time of non-expired statistics in the database.
+     *
+     * @param $qubaids \qubaid_condition
+     * @return int|bool Time of cached record that matches this qubaid_condition or false is non found.
+     */
+    public function get_last_calculated_time($qubaids) {
+        global $DB;
+
+        $timemodified = time() - self::TIME_TO_CACHE;
+        return $DB->get_field_select('question_statistics', 'timemodified', 'hashcode = ? AND timemodified > ?',
+                                     array($qubaids->get_hash_code(), $timemodified), IGNORE_MULTIPLE);
+    }
+
+    /** @var integer Time after which statistics are automatically recomputed. */
+    const TIME_TO_CACHE = 900; // 15 minutes.
+
+    /**
+     * Save stats to db.
+     *
+     * @param $qubaids \qubaid_condition
+     */
+    public function cache($qubaids) {
+        foreach ($this->get_all_slots() as $slot) {
+            $this->for_slot($slot)->cache($qubaids);
+        }
+
+        foreach ($this->get_all_subq_ids() as $subqid) {
+            $this->for_subq($subqid)->cache($qubaids);
+        }
+    }
+
+    /**
+     * Return all sub-questions used.
+     *
+     * @return \object[] array of questions.
+     */
+    public function get_sub_questions() {
+        return $this->subquestions;
+    }
+
+    /**
+     * Are there too many rows of sub-questions and / or variant rows.
+     *
+     * @param array $rows the rows we intend to add.
+     * @return bool
+     */
+    protected function too_many_subq_and_or_variant_rows($rows) {
+        return (count($rows) > static::SUBQ_AND_VARIANT_ROW_LIMIT);
+    }
+
+    /**
+     * From a number of calculated instances find the three instances with min, median and maximum facility index values.
+     *
+     * @param calculated[] $questionstats
+     * @return calculated[] 3 stat objects with minimum, median and maximum facility index.
+     */
+    protected function find_min_median_and_max_facility_stats_objects($questionstats) {
+        $facilities = array();
+        foreach ($questionstats as $key => $questionstat) {
+            $facilities[$key] = (float)$questionstat->facility;
+        }
+        asort($facilities);
+        $facilitykeys = array_keys($facilities);
+        $keyformin = $facilitykeys[0];
+        $keyformedian = $facilitykeys[(int)(round(count($facilitykeys) / 2)-1)];
+        $keyformax = $facilitykeys[count($facilitykeys) - 1];
+        $toreturn = array();
+        foreach (array($keyformin => 'minimumfacility',
+                       $keyformedian => 'medianfacility',
+                       $keyformax => 'maximumfacility') as $key => $stringid) {
+            $questionstats[$key]->minmedianmaxnotice = $stringid;
+            $toreturn[] = $questionstats[$key];
+        }
+        return $toreturn;
+    }
+
+    /**
+     * Return all stats for variants of question in slot $slot.
+     *
+     * @param int $slot
+     * @return calculated[]
+     */
+    protected function all_variant_stats_for_one_slot($slot) {
+        $toreturn = array();
+        if ($variants = $this->get_variants_for_slot($slot)){
+            foreach ($variants as $variant) {
+                $toreturn[] = $this->for_slot($slot, $variant);
+            }
+        }
+        return $toreturn;
+    }
+
+    /**
+     * Return all stats for variants of randomly selected questions for one slot $slot.
+     *
+     * @param int $slot
+     * @return calculated[]
+     */
+    protected function all_subq_variants_for_one_slot($slot) {
+        $toreturn = array();
+        $displayorder = 1;
+        foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
+            if ($variants = $this->get_variants_for_subq($subqid)) {
+                foreach ($variants as $variant) {
+                    $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid, $variant);
+                }
+            }
+            $displayorder++;
+        }
+        return $toreturn;
+    }
+
+    /**
+     * Return all stats for randomly selected questions for one slot $slot.
+     *
+     * @param int $slot
+     * @return calculated[]
+     */
+    protected function all_subqs_for_one_slot($slot) {
+        $displayorder = 1;
+        $toreturn = array();
+        foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
+            $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid);
+            $displayorder++;
+        }
+        return $toreturn;
+    }
+
+    /**
+     * Return all variant or 'sub-question' stats one slot, either :
+     *  - variants of question
+     *  - variants of randomly selected questions
+     *  - randomly selected questions
+     *
+     * @param int $slot
+     * @param bool $limited
+     * @return calculated[]
+     */
+    public function all_subq_and_variant_stats_for_slot($slot, $limited) {
+        // Random question in this slot?
+        if ($this->for_slot($slot)->get_sub_question_ids()) {
+            if ($limited) {
+                $subqvariantstats = $this->all_subq_variants_for_one_slot($slot);
+                if ($this->too_many_subq_and_or_variant_rows($subqvariantstats)) {
+                    // Too many variants from randomly selected questions.
+                    return $this->find_min_median_and_max_facility_stats_objects($subqvariantstats);
+                }
+                $subqstats = $this->all_subqs_for_one_slot($slot);
+                if ($this->too_many_subq_and_or_variant_rows($subqstats)) {
+                    // Too many randomly selected questions.
+                    return $this->find_min_median_and_max_facility_stats_objects($subqstats);
+                }
+            }
+            $toreturn = array();
+            $displaynumber = 1;
+            foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
+                $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid);
+                if ($variants = $this->get_variants_for_subq($subqid)) {
+                    foreach ($variants as $variant) {
+                        $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant);
+                    }
+                }
+                $displaynumber++;
+            }
+            return $toreturn;
+        } else {
+            $variantstats = $this->all_variant_stats_for_one_slot($slot);
+            if ($limited && $this->too_many_subq_and_or_variant_rows($variantstats)) {
+                return $this->find_min_median_and_max_facility_stats_objects($variantstats);
+            } else {
+                return $variantstats;
+            }
+        }
+
+    }
+
+    /**
+     * We need a new object for display. Sub-question stats can appear more than once in different slots.
+     * So we create a clone of the object and then we can set properties on the object that are per slot.
+     *
+     * @param      $displaynumber
+     * @param      $slot
+     * @param      $subqid
+     * @param null $variant
+     * @return calculated_for_subquestion|null
+     */
+    protected function make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant = null) {
+        $slotstat = fullclone($this->for_subq($subqid, $variant));
+        $slotstat->question->number = $this->for_slot($slot)->question->number;
+        $slotstat->subqdisplayorder = $displaynumber;
+        return $slotstat;
+    }
+
+    /**
+     * Call after calculations to output any error messages.
+     *
+     * @return string[] Array of strings describing error messages found during stats calculation.
+     */
+    public function any_error_messages() {
+        $errors = array();
+        foreach ($this->get_all_slots() as $slot) {
+            foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
+                if ($this->for_subq($subqid)->differentweights) {
+                    $name = $this->for_subq($subqid)->question->name;
+                    $errors[] = get_string('erroritemappearsmorethanoncewithdifferentweight', 'quiz_statistics', $name);
+                }
+            }
+        }
+        return $errors;
+
+    }
+
+}
index 198212a..2b4defc 100644 (file)
@@ -57,7 +57,7 @@ class calculated {
 
     /**
      * @var string if this stat has been picked as a min, median or maximum facility value then this string says which stat this
-     *                  is.
+     *                  is. Prepended to question name for display.
      */
     public $minmedianmaxnotice = '';
 
@@ -111,7 +111,6 @@ class calculated {
      */
     public $randomguessscore = null;
 
-
     // End of fields in db.
 
     protected $fieldsindb = array('questionid', 'slot', 'subquestion', 's', 'effectiveweight', 'negcovar', 'discriminationindex',
@@ -179,6 +178,40 @@ class calculated {
      */
     public $timemodified;
 
+    /**
+     * Set up a calculated instance ready to store a question's (or a variant of a slot's question's)
+     * stats for one slot in the quiz.
+     *
+     * @param null|object     $question
+     * @param null|int     $slot
+     * @param null|int $variant
+     */
+    public function __construct($question = null, $slot = null, $variant = null) {
+        if ($question !== null) {
+            $this->questionid = $question->id;
+            $this->maxmark = $question->maxmark;
+            $this->positions = $question->number;
+            $this->question = $question;
+        }
+        if ($slot !== null) {
+            $this->slot = $slot;
+        }
+        if ($variant !== null) {
+            $this->variant = $variant;
+        }
+    }
+
+    /**
+     * @return null|string a string that represents the pool of questions from this question draws if it random or null if not.
+     */
+    public function random_selector_string() {
+        if ($this->question->qtype == 'random') {
+            return $this->question->category .'/'. $this->question->questiontext;
+        } else {
+            return null;
+        }
+    }
+
     /**
      * Cache calculated stats stored in this object in 'question_statistics' table.
      *
@@ -211,4 +244,18 @@ class calculated {
         $this->timemodified = $record->timemodified;
     }
 
+    public function sort_variants() {
+        ksort($this->variantstats);
+    }
+
+    /**
+     * @return int[] array of sub-question ids or empty array if there are none.
+     */
+    public function get_sub_question_ids() {
+        if ($this->subquestions !== '') {
+            return explode(',', $this->subquestions);
+        } else {
+            return array();
+        }
+    }
 }
index de740eb..7229744 100644 (file)
@@ -39,4 +39,17 @@ class calculated_for_subquestion extends calculated {
      * @var int only set immediately before display in the table. The order of display in the table.
      */
     public $subqdisplayorder;
+
+    /**
+     * @param object|null $step the step data for the step that this sub-question was first encountered in.
+     * @param int|null $variant the variant no
+     */
+    public function __construct($step = null, $variant = null) {
+        if ($step !== null) {
+            $this->questionid = $step->questionid;
+            $this->maxmark = $step->maxmark;
+        }
+        $this->variant = $variant;
+
+    }
 }
index 940903f..107024b 100644 (file)
@@ -37,20 +37,20 @@ defined('MOODLE_INTERNAL') || die();
 class calculator {
 
     /**
-     * @var calculated[]
+     * @var all_calculated_for_qubaid_condition all the stats calculated for slots and sub-questions and variants of those
+     *                                                  questions.
      */
-    public $questionstats = array();
-
-    /**
-     * @var calculated_for_subquestion[]
-     */
-    public $subquestionstats = array();
+    protected $stats;
 
     /**
      * @var float
      */
     protected $sumofmarkvariance = 0;
 
+    /**
+     * @var array[] keyed by a string representing the pool of questions that this random question draws from.
+     *              string as returned from {@link \core_question\statistics\questions\calculated::random_selector_string}
+     */
     protected $randomselectors = array();
 
     /**
@@ -58,6 +58,8 @@ class calculator {
      */
     protected $progress;
 
+    protected $statscollectionclassname = '\core_question\statistics\questions\all_calculated_for_qubaid_condition';
+
     /**
      * Constructor.
      *
@@ -71,46 +73,16 @@ class calculator {
             $progress = new \core\progress\null();
         }
         $this->progress = $progress;
-
+        $this->stats = new $this->statscollectionclassname();
         foreach ($questions as $slot => $question) {
-            $this->questionstats[$slot] = $this->new_slot_stats($question, $slot);
+            $this->stats->initialise_for_slot($slot, $question);
+            $this->stats->for_slot($slot)->randomguessscore = $this->get_random_guess_score($question);
         }
     }
 
-    /**
-     * Set up a calculated instance ready to store a questions stats.
-     *
-     * @param $question
-     * @param $slot
-     * @return calculated
-     */
-    protected function new_slot_stats($question, $slot) {
-        $toreturn = new calculated();
-        $toreturn->questionid = $question->id;
-        $toreturn->maxmark = $question->maxmark;
-        $toreturn->question = $question;
-        $toreturn->slot = $slot;
-        $toreturn->positions = $question->number;
-        $toreturn->randomguessscore = $this->get_random_guess_score($question);
-        return $toreturn;
-    }
-
-    /**
-     * Set up a calculated instance ready to store a randomly selected question's stats.
-     *
-     * @param $step
-     * @return calculated_for_subquestion
-     */
-    protected function new_subq_stats($step) {
-        $toreturn = new calculated_for_subquestion();
-        $toreturn->questionid = $step->questionid;
-        $toreturn->maxmark = $step->maxmark;
-        return $toreturn;
-    }
-
     /**
      * @param $qubaids \qubaid_condition
-     * @return array containing two arrays calculated[] and calculated_for_subquestion[].
+     * @return all_calculated_for_qubaid_condition
      */
     public function calculate($qubaids) {
 
@@ -126,43 +98,40 @@ class calculator {
 
                 $this->progress->increment_progress();
 
-                $israndomquestion = ($step->questionid != $this->questionstats[$step->slot]->questionid);
+                $israndomquestion = ($step->questionid != $this->stats->for_slot($step->slot)->questionid);
                 // If this is a variant we have not seen before create a place to store stats calculations for this variant.
-                if (!$israndomquestion && !isset($this->questionstats[$step->slot]->variantstats[$step->variant])) {
-                    $this->questionstats[$step->slot]->variantstats[$step->variant] =
-                        $this->new_slot_stats($this->questionstats[$step->slot]->question, $step->slot);
-                    $this->questionstats[$step->slot]->variantstats[$step->variant]->variant = $step->variant;
+                if (!$israndomquestion && is_null($this->stats->for_slot($step->slot , $step->variant))) {
+                    $this->stats->initialise_for_slot($step->slot, $this->stats->for_slot($step->slot)->question, $step->variant);
+                    $this->stats->for_slot($step->slot, $step->variant)->randomguessscore =
+                                                    $this->get_random_guess_score($this->stats->for_slot($step->slot)->question);
                 }
 
-
                 // Step data walker for main question.
-                $this->initial_steps_walker($step, $this->questionstats[$step->slot], $summarks, true, !$israndomquestion);
+                $this->initial_steps_walker($step, $this->stats->for_slot($step->slot), $summarks, true, !$israndomquestion);
 
                 // If this is a random question do the calculations for sub question stats.
                 if ($israndomquestion) {
-                    if (!isset($this->subquestionstats[$step->questionid])) {
-                        $this->subquestionstats[$step->questionid] = $this->new_subq_stats($step);
-                    } else if ($this->subquestionstats[$step->questionid]->maxmark != $step->maxmark) {
-                        $this->subquestionstats[$step->questionid]->differentweights = true;
+                    if (is_null($this->stats->for_subq($step->questionid))) {
+                        $this->stats->initialise_for_subq($step);
+                    } else if ($this->stats->for_subq($step->questionid)->maxmark != $step->maxmark) {
+                        $this->stats->for_subq($step->questionid)->differentweights = true;
                     }
 
                     // If this is a variant of this subq we have not seen before create a place to store stats calculations for it.
-                    if (!isset($this->subquestionstats[$step->questionid]->variantstats[$step->variant])) {
-                        $this->subquestionstats[$step->questionid]->variantstats[$step->variant] = $this->new_subq_stats($step);
-                        $this->subquestionstats[$step->questionid]->variantstats[$step->variant]->variant = $step->variant;
+                    if (is_null($this->stats->for_subq($step->questionid, $step->variant))) {
+                        $this->stats->initialise_for_subq($step, $step->variant);
                     }
 
-                    $this->initial_steps_walker($step, $this->subquestionstats[$step->questionid], $summarks, false);
+                    $this->initial_steps_walker($step, $this->stats->for_subq($step->questionid), $summarks, false);
 
                     // Extra stuff we need to do in this loop for subqs to keep track of where they need to be displayed later.
 
-                    $number = $this->questionstats[$step->slot]->question->number;
-                    $this->subquestionstats[$step->questionid]->usedin[$number] = $number;
+                    $number = $this->stats->for_slot($step->slot)->question->number;
+                    $this->stats->for_subq($step->questionid)->usedin[$number] = $number;
 
                     // Keep track of which random questions are actually selected from each pool of questions that random
                     // questions are pulled from.
-                    $randomselectorstring = $this->questionstats[$step->slot]->question->category. '/'
-                                                                    .$this->questionstats[$step->slot]->question->questiontext;
+                    $randomselectorstring = $this->stats->for_slot($step->slot)->random_selector_string();
                     if (!isset($this->randomselectors[$randomselectorstring])) {
                         $this->randomselectors[$randomselectorstring] = array();
                     }
@@ -173,67 +142,60 @@ class calculator {
 
             foreach ($this->randomselectors as $key => $notused) {
                 ksort($this->randomselectors[$key]);
+                $this->randomselectors[$key] = implode(',', $this->randomselectors[$key]);
             }
 
-            $subquestions = question_load_questions(array_keys($this->subquestionstats));
+            $this->stats->subquestions = question_load_questions($this->stats->get_all_subq_ids());
             // Compute the statistics for sub questions, if there are any.
-            $this->progress->start_progress('', count($subquestions), 1);
-            foreach ($subquestions as $qid => $subquestion) {
+            $this->progress->start_progress('', count($this->stats->subquestions), 1);
+            foreach ($this->stats->subquestions as $qid => $subquestion) {
                 $this->progress->increment_progress();
-                $subquestion->maxmark = $this->subquestionstats[$qid]->maxmark;
-                $this->subquestionstats[$qid]->question = $subquestion;
-                $this->subquestionstats[$qid]->randomguessscore = $this->get_random_guess_score($subquestion);
-
-                foreach ($this->subquestionstats[$qid]->variantstats as $variantstat) {
-                    $variantstat->question = $subquestion;
-                    $variantstat->randomguessscore = $this->get_random_guess_score($subquestion);
+                $subquestion->maxmark = $this->stats->for_subq($qid)->maxmark;
+                $this->stats->for_subq($qid)->question = $subquestion;
+                $this->stats->for_subq($qid)->randomguessscore = $this->get_random_guess_score($subquestion);
+
+                $this->stats->for_subq($qid)->sort_variants();
+                if ($variants = $this->stats->get_variants_for_subq($qid)) {
+                    foreach ($variants as $variant) {
+                        $this->stats->for_subq($qid, $variant)->question = $subquestion;
+                        $this->stats->for_subq($qid, $variant)->randomguessscore = $this->get_random_guess_score($subquestion);
+                    }
                 }
 
-                $this->initial_question_walker($this->subquestionstats[$qid]);
-
-                if ($this->subquestionstats[$qid]->differentweights) {
-                    // TODO output here really sucks, but throwing is too severe.
-                    global $OUTPUT;
-                    $name = $this->subquestionstats[$qid]->question->name;
-                    echo $OUTPUT->notification( get_string('erroritemappearsmorethanoncewithdifferentweight',
-                                                            'quiz_statistics', $name));
-                }
+                $this->initial_question_walker($this->stats->for_subq($qid));
 
-                if ($this->subquestionstats[$qid]->usedin) {
-                    sort($this->subquestionstats[$qid]->usedin, SORT_NUMERIC);
-                    $this->subquestionstats[$qid]->positions = implode(',', $this->subquestionstats[$qid]->usedin);
+                if ($this->stats->for_subq($qid)->usedin) {
+                    sort($this->stats->for_subq($qid)->usedin, SORT_NUMERIC);
+                    $this->stats->for_subq($qid)->positions = implode(',', $this->stats->for_subq($qid)->usedin);
                 } else {
-                    $this->subquestionstats[$qid]->positions = '';
+                    $this->stats->for_subq($qid)->positions = '';
                 }
             }
             $this->progress->end_progress();
 
-            // Finish computing the averages, and put the subquestion data into the
+            // Finish computing the averages, and put the sub-question data into the
             // corresponding questions.
 
             // This cannot be a foreach loop because we need to have both
             // $question and $nextquestion available, but apart from that it is
             // foreach ($this->questions as $qid => $question).
-            reset($this->questionstats);
-            $this->progress->start_progress('', count($this->questionstats), 1);
-            while (list(, $questionstat) = each($this->questionstats)) {
+            $slots = $this->stats->get_all_slots();
+            $this->progress->start_progress('', count($slots), 1);
+            while (list(, $slot) = each($slots)) {
+                $this->stats->for_slot($slot)->sort_variants();
                 $this->progress->increment_progress();
-                $nextquestionstats = current($this->questionstats);
-
-                $this->initial_question_walker($questionstat);
-
-                // The rest of this loop is again to work out where randomly selected question stats should be displayed.
-                if ($questionstat->question->qtype == 'random') {
-                    $randomselectorstring = $questionstat->question->category .'/'. $questionstat->question->questiontext;
-                    if ($nextquestionstats && $nextquestionstats->question->qtype == 'random') {
-                        $nextrandomselectorstring  =
-                            $nextquestionstats->question->category .'/'. $nextquestionstats->question->questiontext;
-                        if ($randomselectorstring == $nextrandomselectorstring) {
-                            continue; // Next loop iteration.
-                        }
+                $nextslot = current($slots);
+
+                $this->initial_question_walker($this->stats->for_slot($slot));
+
+                // The rest of this loop is to finish working out where randomly selected question stats should be displayed.
+                if ($this->stats->for_slot($slot)->question->qtype == 'random') {
+                    $randomselectorstring = $this->stats->for_slot($slot)->random_selector_string();
+                    if ($nextslot &&  ($randomselectorstring == $this->stats->for_slot($nextslot)->random_selector_string())) {
+                        continue; // Next loop iteration.
                     }
                     if (isset($this->randomselectors[$randomselectorstring])) {
-                        $questionstat->subquestions = implode(',', $this->randomselectors[$randomselectorstring]);
+                        $this->stats->for_slot($slot)->subquestions = $this->randomselectors[$randomselectorstring];
                     }
                 }
             }
@@ -243,122 +205,59 @@ class calculator {
             $this->progress->start_progress('', count($lateststeps), 1);
             foreach ($lateststeps as $step) {
                 $this->progress->increment_progress();
-                $israndomquestion = ($this->questionstats[$step->slot]->question->qtype == 'random');
-                $this->secondary_steps_walker($step, $this->questionstats[$step->slot], $summarks, !$israndomquestion);
+                $israndomquestion = ($this->stats->for_slot($step->slot)->question->qtype == 'random');
+                $this->secondary_steps_walker($step, $this->stats->for_slot($step->slot), $summarks, !$israndomquestion);
 
-                if ($this->questionstats[$step->slot]->subquestions) {
-                    $this->secondary_steps_walker($step, $this->subquestionstats[$step->questionid], $summarks);
+                if ($this->stats->for_slot($step->slot)->subquestions) {
+                    $this->secondary_steps_walker($step, $this->stats->for_subq($step->questionid), $summarks);
                 }
             }
             $this->progress->end_progress();
 
-            $this->progress->start_progress('', count($this->questionstats), 1);
+            $slots = $this->stats->get_all_slots();
+            $this->progress->start_progress('', count($slots), 1);
             $sumofcovariancewithoverallmark = 0;
-            foreach ($this->questionstats as $questionstat) {
+            foreach ($this->stats->get_all_slots() as $slot) {
                 $this->progress->increment_progress();
-                $this->secondary_question_walker($questionstat);
+                $this->secondary_question_walker($this->stats->for_slot($slot));
 
-                $this->sumofmarkvariance += $questionstat->markvariance;
+                $this->sumofmarkvariance += $this->stats->for_slot($slot)->markvariance;
 
-                if ($questionstat->covariancewithoverallmark >= 0) {
-                    $sumofcovariancewithoverallmark += sqrt($questionstat->covariancewithoverallmark);
+                if ($this->stats->for_slot($slot)->covariancewithoverallmark >= 0) {
+                    $sumofcovariancewithoverallmark += sqrt($this->stats->for_slot($slot)->covariancewithoverallmark);
                 }
             }
             $this->progress->end_progress();
 
-            $this->progress->start_progress('', count($this->subquestionstats), 1);
-            foreach ($this->subquestionstats as $subquestionstat) {
+            $subqids = $this->stats->get_all_subq_ids();
+            $this->progress->start_progress('', count($subqids), 1);
+            foreach ($subqids as $subqid) {
                 $this->progress->increment_progress();
-                $this->secondary_question_walker($subquestionstat);
+                $this->secondary_question_walker($this->stats->for_subq($subqid));
             }
             $this->progress->end_progress();
 
-            foreach ($this->questionstats as $questionstat) {
+            foreach ($this->stats->get_all_slots() as $slot) {
                 if ($sumofcovariancewithoverallmark) {
-                    if ($questionstat->negcovar) {
-                        $questionstat->effectiveweight = null;
+                    if ($this->stats->for_slot($slot)->negcovar) {
+                        $this->stats->for_slot($slot)->effectiveweight = null;
                     } else {
-                        $questionstat->effectiveweight = 100 * sqrt($questionstat->covariancewithoverallmark) /
-                            $sumofcovariancewithoverallmark;
+                        $this->stats->for_slot($slot)->effectiveweight =
+                                                        100 * sqrt($this->stats->for_slot($slot)->covariancewithoverallmark) /
+                                                        $sumofcovariancewithoverallmark;
                     }
                 } else {
-                    $questionstat->effectiveweight = null;
+                    $this->stats->for_slot($slot)->effectiveweight = null;
                 }
             }
-            $this->cache_stats($qubaids);
+            $this->stats->cache($qubaids);
 
             // All finished.
             $this->progress->end_progress();
         }
-        return array($this->questionstats, $this->subquestionstats);
+        return $this->stats;
     }
 
-    /**
-     * Load cached statistics from the database.
-     *
-     * @param $qubaids \qubaid_condition
-     * @return array containing two arrays calculated[] and calculated_for_subquestion[].
-     */
-    public function get_cached($qubaids) {
-        global $DB;
-        $timemodified = time() - self::TIME_TO_CACHE;
-        $questionstatrecs = $DB->get_records_select('question_statistics', 'hashcode = ? AND timemodified > ?',
-                                         array($qubaids->get_hash_code(), $timemodified));
-
-        $questionids = array();
-        foreach ($questionstatrecs as $fromdb) {
-            if (is_null($fromdb->variant) && !$fromdb->slot) {
-                $questionids[] = $fromdb->questionid;
-            }
-        }
-        $subquestions = question_load_questions($questionids);
-        foreach ($questionstatrecs as $fromdb) {
-            if (is_null($fromdb->variant)) {
-                if ($fromdb->slot) {
-                    $this->questionstats[$fromdb->slot]->populate_from_record($fromdb);
-                    // Array created in constructor and populated from question.
-                } else {
-                    $this->subquestionstats[$fromdb->questionid] = new calculated_for_subquestion();
-                    $this->subquestionstats[$fromdb->questionid]->populate_from_record($fromdb);
-                    $this->subquestionstats[$fromdb->questionid]->question = $subquestions[$fromdb->questionid];
-                }
-            }
-        }
-        // Add cached variant stats to data structure.
-        foreach ($questionstatrecs as $fromdb) {
-            if (!is_null($fromdb->variant)) {
-                if ($fromdb->slot) {
-                    $newcalcinstance = new calculated();
-                    $this->questionstats[$fromdb->slot]->variantstats[$fromdb->variant] = $newcalcinstance;
-                    $newcalcinstance->question = $this->questionstats[$fromdb->slot]->question;
-                } else {
-                    $newcalcinstance = new calculated_for_subquestion();
-                    $this->subquestionstats[$fromdb->questionid]->variantstats[$fromdb->variant] = $newcalcinstance;
-                    $newcalcinstance->question = $subquestions[$fromdb->questionid];
-                }
-                $newcalcinstance->populate_from_record($fromdb);
-            }
-        }
-        return array($this->questionstats, $this->subquestionstats);
-    }
-
-    /**
-     * Find time of non-expired statistics in the database.
-     *
-     * @param $qubaids \qubaid_condition
-     * @return integer|boolean Time of cached record that matches this qubaid_condition or false is non found.
-     */
-    public function get_last_calculated_time($qubaids) {
-        global $DB;
-
-        $timemodified = time() - self::TIME_TO_CACHE;
-        return $DB->get_field_select('question_statistics', 'timemodified', 'hashcode = ? AND timemodified > ?',
-                                     array($qubaids->get_hash_code(), $timemodified), IGNORE_MULTIPLE);
-    }
-
-    /** @var integer Time after which statistics are automatically recomputed. */
-    const TIME_TO_CACHE = 900; // 15 minutes.
-
     /**
      * Used when computing Coefficient of Internal Consistency by quiz statistics.
      *
@@ -385,7 +284,7 @@ class calculator {
     qa.maxmark,
     qas.fraction * qa.maxmark as mark";
 
-        $lateststeps = $dm->load_questions_usages_latest_steps($qubaids, array_keys($this->questionstats), $fields);
+        $lateststeps = $dm->load_questions_usages_latest_steps($qubaids, $this->stats->get_all_slots(), $fields);
         $summarks = array();
         if ($lateststeps) {
             foreach ($lateststeps as $step) {
@@ -536,7 +435,6 @@ class calculator {
             $stats->discriminativeefficiency = null;
         }
 
-
         if ($dovariantsalso) {
             foreach ($stats->variantstats as $variantstat) {
                 $this->secondary_question_walker($variantstat, false);
@@ -554,16 +452,23 @@ class calculator {
     }
 
     /**
+     * Find time of non-expired statistics in the database.
+     *
      * @param $qubaids \qubaid_condition
+     * @return integer|boolean Time of cached record that matches this qubaid_condition or false is non found.
      */
-    protected function cache_stats($qubaids) {
-         foreach ($this->questionstats as $questionstat) {
-            $questionstat->cache($qubaids);
-         }
-
-        foreach ($this->subquestionstats as $subquestionstat) {
-            $subquestionstat->cache($qubaids);
-        }
+    public function get_last_calculated_time($qubaids) {
+        return $this->stats->get_last_calculated_time($qubaids);
     }
 
+    /**
+     * Load cached statistics from the database.
+     *
+     * @param $qubaids \qubaid_condition
+     * @return all_calculated_for_qubaid_condition
+     */
+    public function get_cached($qubaids) {
+        $this->stats->get_cached($qubaids);
+        return $this->stats;
+    }
 }