From 038014c415f04583e3abf000533e9c2ffaf86441 Mon Sep 17 00:00:00 2001 From: James Pratt Date: Tue, 11 Mar 2014 15:06:35 +0700 Subject: [PATCH] MDL-41760 quiz_statistics : response analysis for first/last/all tries --- lib/db/install.xml | 16 +- lib/db/upgrade.php | 59 +++ .../statistics/lang/en/quiz_statistics.php | 2 + mod/quiz/report/statistics/report.php | 157 ++++--- .../report/statistics/statistics_form.php | 16 +- .../statistics/statistics_question_table.php | 53 ++- .../statistics/tests/fixtures/qstats00.csv | 19 +- .../statistics/tests/fixtures/questions01.csv | 7 + .../statistics/tests/fixtures/questions02.csv | 2 + .../statistics/tests/fixtures/quizzes.csv | 2 + .../tests/fixtures/responsecounts00.csv | 4 +- .../tests/fixtures/responsecounts01.csv | 73 ++++ .../tests/fixtures/responsecounts02.csv | 6 + .../statistics/tests/fixtures/steps01.csv | 83 ++++ .../statistics/tests/fixtures/steps02.csv | 19 + .../stats_from_steps_walkthrough_test.php | 408 ++++++++++-------- .../attempt_walkthrough_from_csv_test.php | 4 +- question/behaviour/behaviourbase.php | 66 ++- .../interactivecountback/behaviour.php | 27 ++ .../classes/statistics/responses/analyser.php | 112 ++--- .../analysis_for_actual_response.php | 109 ++++- .../responses/analysis_for_class.php | 137 ++++-- .../responses/analysis_for_question.php | 51 ++- .../analysis_for_question_all_tries.php | 84 ++++ .../responses/analysis_for_subpart.php | 84 +++- question/engine/datalib.php | 3 +- question/engine/questionattempt.php | 17 +- question/engine/statisticslib.php | 11 +- version.php | 2 +- 29 files changed, 1232 insertions(+), 401 deletions(-) create mode 100644 mod/quiz/report/statistics/tests/fixtures/questions01.csv create mode 100644 mod/quiz/report/statistics/tests/fixtures/questions02.csv create mode 100644 mod/quiz/report/statistics/tests/fixtures/responsecounts01.csv create mode 100644 mod/quiz/report/statistics/tests/fixtures/responsecounts02.csv create mode 100644 mod/quiz/report/statistics/tests/fixtures/steps01.csv create mode 100644 mod/quiz/report/statistics/tests/fixtures/steps02.csv create mode 100644 question/classes/statistics/responses/analysis_for_question_all_tries.php diff --git a/lib/db/install.xml b/lib/db/install.xml index c8eef76325e..e5ab4c80799 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1455,19 +1455,31 @@ + - - + + + + + + + + + + + + +
diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 0a73cc22287..0cafecf427f 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -3124,5 +3124,64 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2014022600.00); } + if ($oldversion < 2014031400.01) { + // Delete any cached stats to force recalculation later, then we can be sure that cached records will have the correct + // field. + $DB->delete_records('question_response_analysis'); + $DB->delete_records('question_statistics'); + $DB->delete_records('quiz_statistics'); + + // Define field response to be dropped from question_response_analysis. + $table = new xmldb_table('question_response_analysis'); + $field = new xmldb_field('rcount'); + + // Conditionally launch drop field response. + if ($dbman->field_exists($table, $field)) { + $dbman->drop_field($table, $field); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2014031400.01); + } + + if ($oldversion < 2014031400.02) { + + // Define table question_response_count to be created. + $table = new xmldb_table('question_response_count'); + + // Adding fields to table question_response_count. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('analysisid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('try', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('rcount', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + + // Adding keys to table question_response_count. + $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id')); + $table->add_key('analysisid', XMLDB_KEY_FOREIGN, array('analysisid'), 'question_response_analysis', array('id')); + + // Conditionally launch create table for question_response_count. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2014031400.02); + } + + if ($oldversion < 2014031400.03) { + + // Define field whichtries to be added to question_response_analysis. + $table = new xmldb_table('question_response_analysis'); + $field = new xmldb_field('whichtries', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null, 'hashcode'); + + // Conditionally launch add field whichtries. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2014031400.03); + } + return true; } diff --git a/mod/quiz/report/statistics/lang/en/quiz_statistics.php b/mod/quiz/report/statistics/lang/en/quiz_statistics.php index fbd425a8e5c..9fa1c41068c 100644 --- a/mod/quiz/report/statistics/lang/en/quiz_statistics.php +++ b/mod/quiz/report/statistics/lang/en/quiz_statistics.php @@ -37,6 +37,7 @@ $string['calculatingallstats'] = 'Calculating statistics for quiz, questions and $string['cic'] = 'Coefficient of internal consistency (for {$a})'; $string['completestatsfilename'] = 'completestats'; $string['count'] = 'Count'; +$string['counttryno'] = 'Count Try {$a}'; $string['coursename'] = 'Course name'; $string['detailedanalysis'] = 'More detailed analysis of the responses to this question'; $string['detailedanalysisforvariant'] = 'More detailed analysis of the responses to variant {$a} of this question'; @@ -110,4 +111,5 @@ $string['statistics:view'] = 'View statistics report'; $string['statsfor'] = 'Quiz statistics (for {$a})'; $string['variant'] = 'Variant'; $string['variantno'] = 'Variant {$a}'; +$string['whichtries'] = 'Analyze responses for'; diff --git a/mod/quiz/report/statistics/report.php b/mod/quiz/report/statistics/report.php index 6a41ddf4c94..cd83de59f3e 100644 --- a/mod/quiz/report/statistics/report.php +++ b/mod/quiz/report/statistics/report.php @@ -18,7 +18,8 @@ * Quiz statistics report class. * * @package quiz_statistics - * @copyright 2008 Jamie Pratt + * @copyright 2014 Open University + * @author James Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -38,9 +39,7 @@ require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php'); */ class quiz_statistics_report extends quiz_default_report { - /** - * @var context_module - */ + /** @var context_module context of this quiz.*/ protected $context; /** @var quiz_statistics_table instance of table class used for main questions stats table. */ @@ -74,6 +73,7 @@ class quiz_statistics_report extends quiz_default_report { $slot = optional_param('slot', 0, PARAM_INT); $variantno = optional_param('variant', null, PARAM_INT); $whichattempts = optional_param('whichattempts', $quiz->grademethod, PARAM_INT); + $whichtries = optional_param('whichtries', question_attempt::LAST_TRY, PARAM_ALPHA); $pageoptions = array(); $pageoptions['id'] = $cm->id; @@ -81,18 +81,18 @@ class quiz_statistics_report extends quiz_default_report { $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions); - $mform = new quiz_statistics_settings_form($reporturl); + $mform = new quiz_statistics_settings_form($reporturl, compact('quiz')); - $mform->set_data(array('whichattempts' => $whichattempts)); - - if ($fromform = $mform->get_data()) { - $whichattempts = $fromform->whichattempts; - } + $mform->set_data(array('whichattempts' => $whichattempts, 'whichtries' => $whichtries)); if ($whichattempts != $quiz->grademethod) { $reporturl->param('whichattempts', $whichattempts); } + if ($whichtries != question_attempt::LAST_TRY) { + $reporturl->param('whichtries', $whichtries); + } + // Find out current groups mode. $currentgroup = $this->get_current_group($cm, $course, $this->context); $nostudentsingroup = false; // True if a group is selected and there is no one in it. @@ -145,7 +145,7 @@ class quiz_statistics_report extends quiz_default_report { // Get the data to be displayed. $progress = $this->get_progress_trace_instance(); list($quizstats, $questionstats) = - $this->get_all_stats_and_analysis($quiz, $whichattempts, $groupstudents, $questions, $progress); + $this->get_all_stats_and_analysis($quiz, $whichattempts, $whichtries, $groupstudents, $questions, $progress); } else { // Or create empty stats containers. $quizstats = new \quiz_statistics\calculated($whichattempts); @@ -191,7 +191,7 @@ class quiz_statistics_report extends quiz_default_report { $this->output_statistics_graph($quiz->id, $currentgroup, $whichattempts); } - $this->output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl); + $this->output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl, $whichtries); } $this->table->export_class_instance()->finish_document(); @@ -207,7 +207,8 @@ class quiz_statistics_report extends quiz_default_report { $variantno, $questionstats->for_subq($qid, $variantno)->s, $reporturl, - $qubaids); + $qubaids, + $whichtries); // Back to overview link. echo $OUTPUT->box('' . get_string('backtoquizreport', 'quiz_statistics') . '', @@ -233,7 +234,8 @@ class quiz_statistics_report extends quiz_default_report { $variantno, $questionstats->for_slot($slot, $variantno)->s, $reporturl, - $qubaids); + $qubaids, + $whichtries); } if (!$this->table->is_downloading()) { // Back to overview link. @@ -256,7 +258,7 @@ class quiz_statistics_report extends quiz_default_report { } else { // On-screen display of overview report. echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3); - echo $this->output_caching_info($quizstats, $quiz->id, $groupstudents, $whichattempts, $reporturl); + echo $this->output_caching_info($quizstats->timemodified, $quiz->id, $groupstudents, $whichattempts, $reporturl); echo $this->everything_download_options(); $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz); echo $this->output_quiz_info_table($quizinfo); @@ -273,6 +275,7 @@ class quiz_statistics_report extends quiz_default_report { /** * Display the statistical and introductory information about a question. * Only called when not downloading. + * * @param object $quiz the quiz settings. * @param \core_question\statistics\questions\calculated $questionstat the question to report on. */ @@ -340,6 +343,8 @@ class quiz_statistics_report extends quiz_default_report { } /** + * Output question text in a box with urls appropriate for a preview of the question. + * * @param object $question question data. * @return string HTML of question text, ready for display. */ @@ -363,8 +368,10 @@ class quiz_statistics_report extends quiz_default_report { * @param int $s * @param moodle_url $reporturl the URL to redisplay this report. * @param qubaid_condition $qubaids + * @param string $whichtries */ - protected function output_individual_question_response_analysis($question, $variantno, $s, $reporturl, $qubaids) { + protected function output_individual_question_response_analysis($question, $variantno, $s, $reporturl, $qubaids, + $whichtries = question_attempt::LAST_TRY) { global $OUTPUT; if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) { @@ -399,8 +406,8 @@ class quiz_statistics_report extends quiz_default_report { } } - $responesanalyser = new \core_question\statistics\responses\analyser($question); - $responseanalysis = $responesanalyser->load_cached($qubaids); + $responesanalyser = new \core_question\statistics\responses\analyser($question, $whichtries); + $responseanalysis = $responesanalyser->load_cached($qubaids, $whichtries); $qtable->question_setup($reporturl, $question, $s, $responseanalysis); if ($this->table->is_downloading()) { @@ -427,6 +434,7 @@ class quiz_statistics_report extends quiz_default_report { /** * Output the table that lists all the questions in the quiz with their statistics. + * * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats the stats for all questions in * the quiz including subqs and * variants. @@ -442,7 +450,8 @@ class quiz_statistics_report extends quiz_default_report { } /** - * Output the table of overall quiz statistics. + * Return HTML for table of overall quiz statistics. + * * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}. * @return string the HTML. */ @@ -463,6 +472,7 @@ class quiz_statistics_report extends quiz_default_report { /** * Download the table of overall quiz statistics. + * * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}. */ protected function download_quiz_info_table($quizinfo) { @@ -493,6 +503,7 @@ class quiz_statistics_report extends quiz_default_report { /** * Output the HTML needed to show the statistics graph. + * * @param $quizid * @param $currentgroup * @param $whichattempts @@ -516,13 +527,15 @@ class quiz_statistics_report extends quiz_default_report { * $quiz->grademethod ie. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student. + * @param string $whichtries which tries to analyse for response analysis. Will be one of + * question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. * @param array $groupstudents students in this group. * @param array $questions full question data. * @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) { + public function get_all_stats_and_analysis($quiz, $whichattempts, $whichtries, $groupstudents, $questions, $progress = null) { if ($progress === null) { $progress = new \core\progress\null(); @@ -534,10 +547,9 @@ class quiz_statistics_report extends quiz_default_report { $quizcalc = new \quiz_statistics\calculator($progress); + $progress->start_progress('', 3); if ($quizcalc->get_last_calculated_time($qubaids) === false) { - $progress->start_progress('', 3); - // Recalculate now. $questionstats = $qcalc->calculate($qubaids); $progress->progress(1); @@ -545,17 +557,24 @@ class quiz_statistics_report extends quiz_default_report { $quizstats = $quizcalc->calculate($quiz->id, $whichattempts, $groupstudents, count($questions), $qcalc->get_sum_of_mark_variance()); $progress->progress(2); - if ($quizstats->s()) { - $subquestions = $questionstats->get_sub_questions(); - $this->analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids, $progress); - } - $progress->progress(3); - $progress->end_progress(); } else { $quizstats = $quizcalc->get_cached($qubaids); + $progress->progress(1); $questionstats = $qcalc->get_cached($qubaids); + $progress->progress(2); } + if ($quizstats->s()) { + $subquestions = $questionstats->get_sub_questions(); + $this->analyse_responses_for_all_questions_and_subquestions($questions, + $subquestions, + $qubaids, + $whichtries, + $progress); + } + $progress->progress(3); + $progress->end_progress(); + return array($quizstats, $questionstats); } @@ -576,7 +595,17 @@ class quiz_statistics_report extends quiz_default_report { return $this->progress; } - protected function analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids, $progress = null) { + /** + * Analyse responses for all questions and sub questions in this quiz. + * + * @param object[] $questions as returned by self::load_and_initialise_questions_for_calculations + * @param object[] $subquestions full question objects. + * @param qubaid_condition $qubaids the question usages whose responses to analyse. + * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. + * @param null|\core\progress\base $progress Used to indicate progress of task. + */ + protected function analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids, + $whichtries, $progress = null) { if ($progress === null) { $progress = new \core\progress\null(); } @@ -584,15 +613,25 @@ class quiz_statistics_report extends quiz_default_report { // Starting response analysis tasks. $progress->start_progress('', count($questions) + count($subquestions)); - $done = $this->analyse_responses_for_questions($questions, $qubaids, $progress); + $done = $this->analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress); - $this->analyse_responses_for_questions($subquestions, $qubaids, $progress, $done); + $this->analyse_responses_for_questions($subquestions, $qubaids, $whichtries, $progress, $done); // Finished all response analysis tasks. $progress->end_progress(); } - protected function analyse_responses_for_questions($questions, $qubaids, $progress = null, $done = array()) { + /** + * Analyse responses for an array of questions or sub questions. + * + * @param object[] $questions as returned by self::load_and_initialise_questions_for_calculations. + * @param qubaid_condition $qubaids the question usages whose responses to analyse. + * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. + * @param null|\core\progress\base $progress Used to indicate progress of task. + * @param int[] $done array keys are ids of questions that have been analysed before calling method. + * @return array array keys are ids of questions that were analysed after this method call. + */ + protected function analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress = null, $done = array()) { $countquestions = count($questions); if (!$countquestions) { return array(); @@ -604,8 +643,10 @@ class quiz_statistics_report extends quiz_default_report { foreach ($questions as $question) { $progress->increment_progress(); if (question_bank::get_qtype($question->qtype, false)->can_analyse_responses() && !isset($done[$question->id])) { - $responesstats = new \core_question\statistics\responses\analyser($question); - $responesstats->calculate($qubaids); + $responesstats = new \core_question\statistics\responses\analyser($question, $whichtries); + if ($responesstats->get_last_analysed_time($qubaids, $whichtries) === false) { + $responesstats->calculate($qubaids, $whichtries); + } } $done[$question->id] = 1; } @@ -614,7 +655,10 @@ class quiz_statistics_report extends quiz_default_report { } /** - * @return string HTML snipped for the Download full report as UI. + * Return a little form for the user to request to download the full report, including quiz stats and response analysis for + * all questions and sub-questions. + * + * @return string HTML. */ protected function everything_download_options() { $downloadoptions = $this->table->get_download_menu(); @@ -635,9 +679,9 @@ class quiz_statistics_report extends quiz_default_report { } /** - * Generate the snipped of HTML that says when the stats were last caculated, - * with a recalcuate now button. - * @param object $quizstats the overall quiz statistics. + * Return HTML for a message that says when the stats were last calculated and a 'recalculate now' button. + * + * @param int $lastcachetime the time the stats were last cached. * @param int $quizid the quiz id. * @param array $groupstudents ids of students in the group or empty array if groups not used. * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in @@ -645,13 +689,12 @@ class quiz_statistics_report extends quiz_default_report { * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student. * @param moodle_url $reporturl url for this report - * @return string a HTML snipped saying when the stats were last computed, - * or blank if that is not appropriate. + * @return string HTML. */ - protected function output_caching_info($quizstats, $quizid, $groupstudents, $whichattempts, $reporturl) { + protected function output_caching_info($lastcachetime, $quizid, $groupstudents, $whichattempts, $reporturl) { global $DB, $OUTPUT; - if (empty($quizstats->timemodified)) { + if (empty($lastcachetime)) { return ''; } @@ -661,7 +704,7 @@ class quiz_statistics_report extends quiz_default_report { SELECT COUNT(1) FROM $fromqa WHERE $whereqa - AND quiza.timefinish > {$quizstats->timemodified}", $qaparams); + AND quiza.timefinish > {$lastcachetime}", $qaparams); if (!$count) { $count = 0; @@ -669,7 +712,7 @@ class quiz_statistics_report extends quiz_default_report { // Generate the output. $a = new stdClass(); - $a->lastcalculated = format_time(time() - $quizstats->timemodified); + $a->lastcalculated = format_time(time() - $lastcachetime); $a->count = $count; $recalcualteurl = new moodle_url($reporturl, @@ -686,8 +729,9 @@ class quiz_statistics_report extends quiz_default_report { } /** - * Clear the cached data for a particular report configuration. This will - * trigger a re-computation the next time the report is displayed. + * Clear the cached data for a particular report configuration. This will trigger a re-computation the next time the report + * is displayed. + * * @param $qubaids qubaid_condition */ protected function clear_cached_data($qubaids) { @@ -698,6 +742,8 @@ class quiz_statistics_report extends quiz_default_report { } /** + * Load the questions in this quiz and add some properties to the objects needed in the reports. + * * @param object $quiz the quiz. * @return array of questions for this quiz. */ @@ -726,8 +772,13 @@ class quiz_statistics_report extends quiz_default_report { * @param $questions * @param $questionstats * @param $reporturl + * @param $whichtries string */ - protected function output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl) { + protected function output_all_question_response_analysis($qubaids, + $questions, + $questionstats, + $reporturl, + $whichtries = question_attempt::LAST_TRY) { foreach ($questions as $slot => $question) { if (question_bank::get_qtype( $question->qtype, false)->can_analyse_responses() @@ -738,14 +789,16 @@ class quiz_statistics_report extends quiz_default_report { $variantno, $questionstats->for_slot($slot, $variantno)->s, $reporturl, - $qubaids); + $qubaids, + $whichtries); } } else { $this->output_individual_question_response_analysis($question, null, $questionstats->for_slot($slot)->s, $reporturl, - $qubaids); + $qubaids, + $whichtries); } } else if ($subqids = $questionstats->for_slot($slot)->get_sub_question_ids()) { foreach ($subqids as $subqid) { @@ -756,7 +809,8 @@ class quiz_statistics_report extends quiz_default_report { $variantno, $questionstats->for_subq($subqid, $variantno)->s, $reporturl, - $qubaids); + $qubaids, + $whichtries); } } else { $this->output_individual_question_response_analysis( @@ -764,7 +818,8 @@ class quiz_statistics_report extends quiz_default_report { null, $questionstats->for_subq($subqid)->s, $reporturl, - $qubaids); + $qubaids, + $whichtries); } } diff --git a/mod/quiz/report/statistics/statistics_form.php b/mod/quiz/report/statistics/statistics_form.php index 114ae32a7c4..0ca216fa889 100644 --- a/mod/quiz/report/statistics/statistics_form.php +++ b/mod/quiz/report/statistics/statistics_form.php @@ -18,7 +18,8 @@ * Quiz statistics settings form definition. * * @package quiz_statistics - * @copyright 2008 Jamie Pratt + * @copyright 2014 Open University + * @author James Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -29,7 +30,9 @@ require_once($CFG->libdir . '/formslib.php'); /** * This is the settings form for the quiz statistics report. * - * @copyright 2008 Jamie Pratt + * @package quiz_statistics + * @copyright 2014 Open University + * @author James Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_statistics_settings_form extends moodleform { @@ -45,6 +48,15 @@ class quiz_statistics_settings_form extends moodleform { $mform->addElement('select', 'whichattempts', get_string('calculatefrom', 'quiz_statistics'), $options); + if (quiz_allows_multiple_tries($this->_customdata['quiz'])) { + $mform->addElement('select', 'whichtries', get_string('whichtries', 'quiz_statistics'), array( + question_attempt::FIRST_TRY => get_string('firsttry', 'question'), + question_attempt::LAST_TRY => get_string('lasttry', 'question'), + question_attempt::ALL_TRIES => get_string('alltries', 'question')) + ); + $mform->setDefault('whichtries', question_attempt::LAST_TRY); + } $mform->addElement('submit', 'submitbutton', get_string('preferencessave', 'quiz_overview')); } + } diff --git a/mod/quiz/report/statistics/statistics_question_table.php b/mod/quiz/report/statistics/statistics_question_table.php index 5591e50180a..2545ffe0fe5 100644 --- a/mod/quiz/report/statistics/statistics_question_table.php +++ b/mod/quiz/report/statistics/statistics_question_table.php @@ -15,10 +15,11 @@ // along with Moodle. If not, see . /** - * Quiz statistics report, table for showing statistics about a particular question. + * Quiz statistics report, table for showing response analysis for a particular question (or sub question). * * @package quiz_statistics - * @copyright 2008 Jamie Pratt + * @copyright 2014 Open University + * @author James Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -31,10 +32,11 @@ require_once($CFG->libdir . '/tablelib.php'); * * Lists the responses that students made to this question, with frequency counts. * - * The responses may be grouped, either by subpart of the question, or by the + * The responses may be grouped, either by sub-part of the question, or by the * answer they match. * - * @copyright 2008 Jamie Pratt + * @copyright 2014 Open University + * @author James Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_statistics_question_table extends flexible_table { @@ -46,6 +48,7 @@ class quiz_statistics_question_table extends flexible_table { /** * Constructor. + * * @param int $qid the id of the particular question whose statistics are being * displayed. */ @@ -54,6 +57,8 @@ class quiz_statistics_question_table extends flexible_table { } /** + * Set up columns and column names and other table settings. + * * @param moodle_url $reporturl * @param object $questiondata * @param integer $s number of attempts on this question. @@ -93,8 +98,16 @@ class quiz_statistics_question_table extends flexible_table { $columns[] = 'fraction'; $headers[] = get_string('optiongrade', 'quiz_statistics'); - $columns[] = 'count'; - $headers[] = get_string('count', 'quiz_statistics'); + if (!$responseanalysis->has_multiple_tries_data()) { + $columns[] = 'totalcount'; + $headers[] = get_string('count', 'quiz_statistics'); + } else { + $countcolumns = range(1, $responseanalysis->get_maximum_tries()); + foreach ($countcolumns as $countcolumn) { + $columns[] = 'trycount'.$countcolumn; + $headers[] = get_string('counttryno', 'quiz_statistics', $countcolumn); + } + } $columns[] = 'frequency'; $headers[] = get_string('frequency', 'quiz_statistics'); @@ -113,6 +126,12 @@ class quiz_statistics_question_table extends flexible_table { parent::setup(); } + /** + * Take a float where 1 represents 100% and return a string representing the percentage. + * + * @param float $fraction The fraction. + * @return string The fraction as a percentage. + */ protected function format_percentage($fraction) { return format_float($fraction * 100, 2) . '%'; } @@ -139,7 +158,27 @@ class quiz_statistics_question_table extends flexible_table { if (!$this->s) { return ''; } + return $this->format_percentage($response->totalcount / $this->s); + } - return $this->format_percentage($response->count / $this->s); + /** + * If there is not a col_{column name} method then we call this method. If it returns null + * that means just output the property as in the table raw data. If this returns none null + * then this is the output for this cell of the table. + * + * @param string $colname The name of this column. + * @param object $response The raw data for this row. + * @return string|null The value for this cell of the table or null means use raw data. + */ + public function other_cols($colname, $response) { + if (preg_match('/^trycount(\d+)$/', $colname, $matches)) { + if (isset($response->trycount[$matches[1]])) { + return $response->trycount[$matches[1]]; + } else { + return 0; + } + } else { + return null; + } } } diff --git a/mod/quiz/report/statistics/tests/fixtures/qstats00.csv b/mod/quiz/report/statistics/tests/fixtures/qstats00.csv index 1ba861ef060..74bae168598 100644 --- a/mod/quiz/report/statistics/tests/fixtures/qstats00.csv +++ b/mod/quiz/report/statistics/tests/fixtures/qstats00.csv @@ -1,8 +1,11 @@ -slot,facility,sd,effectiveweight,covariance,markvariance,othermarkvariance,discriminationindex,covariancemax,discriminativeefficiency -1,0.704,0.4513682901,21.2922742344,-0.022555556,0.2037333333,0.5002777794,-7.0650767526,0.2385555565,-9.4550536967 -2,0.48,0.5099019514,18.8979800309,-0.1172777785,0.26,0.6334555578,-28.8982125772,0.318833334,-36.7834118938 -3,0.973333332,0.13333334,4.443012573,-0.0098888894,0.0177777796,0.6609,-9.1230674268,0.045666669,-21.6545012165 -4,0.68,0.4760952286,18.9347251357,-0.0833888893,0.2266666667,0.5990111128,-22.6306444113,0.2652222232,-31.4411395613 -5,0.52,0.3055050463,11.1450138688,-0.0436944444,0.0933333333,0.6529555563,-17.6997047674,0.2063055556,-21.1794802584 -6,0.64,0.4898979486,9.8081339177,-0.2015555547,0.24,0.8220111101,-45.3785178421,0.3539999995,-56.9365974439 -7,0.62,0.331662479,15.4788602394,-0.0142499998,0.11,0.5774000005,-5.6543166602,0.2190833335,-6.5043742058 +slot,subqname,variant,s,facility,sd,effectiveweight,covariance,markvariance,othermarkvariance,discriminationindex,covariancemax,discriminativeefficiency,maxmark +1,,,25,0.704,0.4513682901,21.2922742344,-0.022555556,0.2037333333,0.5002777794,-7.0650767526,0.2385555565,-9.4550536967,1 +1,numerical,,12,0.583333333,0.514928651,**NULL**,,,,35.803933,,39.39393939,1 +2,,,25,0.48,0.5099019514,18.8979800309,-0.1172777785,0.26,0.6334555578,-28.8982125772,0.318833334,-36.7834118938,1 +2,,1,6,0.50,0.5477225575,**NULL**,,,,-10.5999788,,-14.28571429,1 +2,,8,5,0.40,0.547722558,**NULL**,,,,-57.77466679,,-71.05263241,1 +3,,,25,0.973333332,0.13333334,4.443012573,-0.0098888894,0.0177777796,0.6609,-9.1230674268,0.045666669,-21.6545012165,1 +4,,,25,0.68,0.4760952286,18.9347251357,-0.0833888893,0.2266666667,0.5990111128,-22.6306444113,0.2652222232,-31.4411395613,1 +5,,,25,0.52,0.3055050463,11.1450138688,-0.0436944444,0.0933333333,0.6529555563,-17.6997047674,0.2063055556,-21.1794802584,1 +6,,,25,0.64,0.4898979486,9.8081339177,-0.2015555547,0.24,0.8220111101,-45.3785178421,0.3539999995,-56.9365974439,1 +7,,,25,0.62,0.331662479,15.4788602394,-0.0142499998,0.11,0.5774000005,-5.6543166602,0.2190833335,-6.5043742058,1 diff --git a/mod/quiz/report/statistics/tests/fixtures/questions01.csv b/mod/quiz/report/statistics/tests/fixtures/questions01.csv new file mode 100644 index 00000000000..76c0bd21b78 --- /dev/null +++ b/mod/quiz/report/statistics/tests/fixtures/questions01.csv @@ -0,0 +1,7 @@ +slot,type,which,cat,mark,overrides.hint.0.text,overrides.hint.0.format,overrides.hint.1.text,overrides.hint.1.format,overrides.hint.2.text,overrides.hint.2.format,overrides.hint.3.text,overrides.hint.3.format,overrides.shuffleanswers +1,random,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 +,shortanswer,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 +,numerical,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 +2,calculatedsimple,sumwithvariants,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 +3,match,,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 +4,truefalse,,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0 diff --git a/mod/quiz/report/statistics/tests/fixtures/questions02.csv b/mod/quiz/report/statistics/tests/fixtures/questions02.csv new file mode 100644 index 00000000000..9977455d8f3 --- /dev/null +++ b/mod/quiz/report/statistics/tests/fixtures/questions02.csv @@ -0,0 +1,2 @@ +slot,type,which,cat,mark,overrides.hint.0.text,overrides.hint.0.format,overrides.hint.1.text,overrides.hint.1.format,overrides.hint.2.text,overrides.hint.2.format,overrides.hint.3.text,overrides.hint.3.format,overrides.hint.4.text,overrides.hint.4.format,overrides.shuffleanswers +1,match,,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,"Hint 5",0,0 diff --git a/mod/quiz/report/statistics/tests/fixtures/quizzes.csv b/mod/quiz/report/statistics/tests/fixtures/quizzes.csv index 1a3aaa8975d..63dd846c8bd 100644 --- a/mod/quiz/report/statistics/tests/fixtures/quizzes.csv +++ b/mod/quiz/report/statistics/tests/fixtures/quizzes.csv @@ -1,2 +1,4 @@ testnumber,preferredbehaviour 00,deferredfeedback +01,interactive +02,interactive diff --git a/mod/quiz/report/statistics/tests/fixtures/responsecounts00.csv b/mod/quiz/report/statistics/tests/fixtures/responsecounts00.csv index 872ca30ac35..50ba9cae4c9 100644 --- a/mod/quiz/report/statistics/tests/fixtures/responsecounts00.csv +++ b/mod/quiz/report/statistics/tests/fixtures/responsecounts00.csv @@ -1,4 +1,4 @@ -slot,randq,variant,subpart,modelresponse,actualresponse,count +slot,randq,variant,subpart,modelresponse,actualresponse,totalcount 1,numerical,1,1,"3.142 (3.142..3.142)",3.142,1 1,numerical,1,1,"3.14 (3.14..3.14)",3.14,7 1,numerical,1,1,"3.1 (3.1..3.1)",3.1,4 @@ -10,7 +10,7 @@ slot,randq,variant,subpart,modelresponse,actualresponse,count 2,,1,1,[NO MATCH],-0.2,1 2,,1,1,[NO MATCH],-1,1 2,,4,1,{a} + {b} (±0.01 Relative),19.4,2 -2,,4,1,[NO RESPONSE],,1 +2,,4,1,"[NO RESPONSE]",,1 2,,4,1,[NO MATCH],-0.4,1 3,,1,1,frog: amphibian,amphibian,25 3,,1,2,cat: mammal,mammal,24 diff --git a/mod/quiz/report/statistics/tests/fixtures/responsecounts01.csv b/mod/quiz/report/statistics/tests/fixtures/responsecounts01.csv new file mode 100644 index 00000000000..de81954c494 --- /dev/null +++ b/mod/quiz/report/statistics/tests/fixtures/responsecounts01.csv @@ -0,0 +1,73 @@ +slot,randq,variant,subpart,modelresponse,actualresponse,count1,count2,count3,count4,count5,totalcount +1,shortanswer,1,1,frog,,0,0,0,0,0,0 +1,shortanswer,1,1,toad,toad,1,0,1,0,0,2 +1,shortanswer,1,1,*,butterfly,1,0,0,0,0,1 +1,shortanswer,1,1,*,dog,1,1,0,0,0,2 +1,shortanswer,1,1,*,chicken,0,0,1,0,0,1 +1,shortanswer,1,1,*,Tod,1,0,0,0,0,1 +1,shortanswer,1,1,*,Tony,0,1,0,0,0,1 +1,shortanswer,1,1,*,Sharon,0,0,1,0,0,1 +1,shortanswer,1,1,*,snake,0,1,0,0,0,1 +1,shortanswer,1,1,*,snakes,0,0,1,0,0,1 +1,shortanswer,1,1,*,Snakes,0,0,0,1,0,1 +1,shortanswer,1,1,*,SnakeS,0,0,0,0,1,1 +1,shortanswer,1,1,*,goat,1,0,0,0,0,1 +1,shortanswer,1,1,*,"Mexican burrowing caecilian",0,0,1,0,0,1 +1,shortanswer,1,1,*,newt,0,0,0,1,0,1 +1,shortanswer,1,1,*,human,0,0,0,0,1,1 +1,shortanswer,1,1,*,eggs,1,0,0,0,0,1 +1,shortanswer,1,1,"[No response]",,0,0,0,0,0,0 +1,numerical,1,1,3.14 (3.14..3.14),3.14,2,0,0,0,0,2 +1,numerical,1,1,3.142 (3.142..3.142),,0,0,0,0,0,0 +1,numerical,1,1,3.1 (3.1..3.1),3.1,1,0,0,0,0,1 +1,numerical,1,1,3 (3..3),,0,0,0,0,0,0 +1,numerical,1,1,*,2,1,0,0,0,0,1 +1,numerical,1,1,*,20,0,1,0,0,0,1 +1,numerical,1,1,*,34,0,0,1,0,0,1 +1,numerical,1,1,[No response],,0,0,0,0,0,0 +2,,1,1,{a} + {b} (±0.01 Relative),9.9,0,0,0,0,1,1 +2,,1,1,[Did not match any answer],23,1,0,0,0,0,1 +2,,1,1,[Did not match any answer],22,0,1,0,0,0,1 +2,,1,1,[Did not match any answer],21,0,0,1,0,0,1 +2,,1,1,[Did not match any answer],9,0,0,0,1,0,1 +2,,1,1,[No response],,0,0,0,0,0,0 +2,,2,1,{a} + {b} (±0.01 Relative),8.5,1,0,0,0,1,2 +2,,2,1,"[Did not match any answer]",19.4,0,1,0,0,0,1 +2,,2,1,[Did not match any answer],4.5,1,0,0,0,0,1 +2,,2,1,"[Did not match any answer]",8,0,0,0,1,0,1 +2,,2,1,[No response],,0,0,0,0,0,0 +2,,3,1,{a} + {b} (±0.01 Relative),3.3,0,1,0,0,0,1 +2,,3,1,[Did not match any answer],19.4,1,0,0,0,0,1 +2,,3,1,[No response],,0,0,0,0,0,0 +2,,4,1,{a} + {b} (±0.01 Relative),19.4,2,0,0,0,0,2 +2,,4,1,{a} + {b} (±0.01 Relative),19.3,1,0,0,0,0,1 +2,,4,1,[Did not match any answer],,0,0,0,0,0,0 +2,,4,1,[No response],,0,0,0,0,0,0 +2,,6,1,"{a} + {b} (±0.01 Relative)",9.4,1,0,0,0,0,1 +2,,6,1,"[Did not match any answer]",,0,0,0,0,0,0 +2,,6,1,"[No response]",,0,0,0,0,0,0 +2,,9,1,"{a} + {b} (±0.01 Relative)",,0,0,0,0,0,0 +2,,9,1,"[Did not match any answer]",7,1,0,0,0,0,1 +2,,9,1,"[No response]",,0,0,0,0,0,0 +2,,10,1,"{a} + {b} (±0.01 Relative)",,0,0,0,0,0,0 +2,,10,1,"[Did not match any answer]",555,1,0,0,0,0,1 +2,,10,1,"[Did not match any answer]",44,0,1,0,0,0,1 +2,,10,1,"[Did not match any answer]",22,0,0,1,0,0,1 +2,,10,1,"[Did not match any answer]",11,0,0,0,1,0,1 +2,,10,1,"[Did not match any answer]",12,0,0,0,0,1,1 +2,,10,1,"[No response]",,0,0,0,0,0,0 +3,,1,1,"frog: amphibian",amphibian,8,0,1,0,0,9 +3,,1,1,frog: mammal,mammal,0,0,0,1,1,2 +3,,1,1,frog: insect,insect,4,3,0,0,0,7 +3,,1,1,[No response],,0,0,0,0,0,0 +3,,1,2,"cat: amphibian",amphibian,8,0,1,1,0,10 +3,,1,2,cat: mammal,mammal,0,1,1,2,0,4 +3,,1,2,"cat: insect",insect,4,1,1,1,0,7 +3,,1,2,[No response],,0,0,0,0,0,0 +3,,1,3,"newt: amphibian",amphibian,6,2,1,0,0,9 +3,,1,3,"newt: mammal",mammal,3,0,0,1,0,5 +3,,1,3,newt: insect,insect,3,2,0,0,0,5 +3,,1,3,[No response],,0,0,0,0,0,0 +4,,1,1,False,,3,0,0,0,0,3 +4,,1,1,True,,9,0,0,0,0,9 +4,,1,1,[No response],,0,0,0,0,0,0 diff --git a/mod/quiz/report/statistics/tests/fixtures/responsecounts02.csv b/mod/quiz/report/statistics/tests/fixtures/responsecounts02.csv new file mode 100644 index 00000000000..c766a6e5baa --- /dev/null +++ b/mod/quiz/report/statistics/tests/fixtures/responsecounts02.csv @@ -0,0 +1,6 @@ +slot,subpart,modelresponse,actualresponse,totalcount,count1,count2,count3,count4,count5 +1,1,frog: insect,insect,2,2,0,0,0,0 +1,1,"frog: mammal",mammal,1,0,0,1,0,0 +1,2,"cat: insect",insect,2,1,1,0,0,0 +1,2,cat: amphibian,amphibian,1,1,0,0,0,0 +1,3,newt: insect,insect,2,2,0,0,0,0 diff --git a/mod/quiz/report/statistics/tests/fixtures/steps01.csv b/mod/quiz/report/statistics/tests/fixtures/steps01.csv new file mode 100644 index 00000000000..952a5376f22 --- /dev/null +++ b/mod/quiz/report/statistics/tests/fixtures/steps01.csv @@ -0,0 +1,83 @@ +quizattempt,firstname,lastname,finished,randqs.1,responses.1.-submit,responses.1.-tryagain,responses.1.answer,responses.2.-submit,responses.2.-tryagain,responses.2.answer,variants.2,responses.3.-submit,responses.3.-tryagain,responses.3.0,responses.3.1,responses.3.2,responses.4.-submit,responses.4.answer +1,John,Jones,0,shortanswer,1,0,butterfly,0,0,19.4,4,0,0,,amphibian,,0,1 +1,John,Jones,0,shortanswer,0,1,butterfly,0,0,19.4,4,1,0,insect,insect,insect,0,1 +1,John,Jones,0,shortanswer,1,0,dog,0,0,19.4,4,0,1,insect,insect,insect,0,1 +1,John,Jones,0,shortanswer,0,1,dog,0,0,19.4,4,1,0,insect,insect,amphibian,0,1 +1,John,Jones,0,shortanswer,1,0,chicken,0,0,19.4,4,0,1,insect,insect,amphibian,0,1 +1,John,Jones,0,shortanswer,0,0,chicken,0,0,19.4,4,1,0,insect,mammal,amphibian,0,1 +1,John,Jones,0,shortanswer,0,0,chicken,0,0,19.4,4,0,1,insect,mammal,amphibian,0,1 +1,John,Jones,0,shortanswer,0,0,chicken,0,0,19.4,4,1,0,insect,amphibian,amphibian,0,1 +1,John,Jones,0,shortanswer,0,0,chicken,0,0,19.4,4,0,1,insect,amphibian,amphibian,0,1 +1,John,Jones,1,shortanswer,0,0,chicken,0,0,19.4,4,1,0,mammal,amphibian,amphibian,0,1 +2,Han,Solo,0,shortanswer,1,0,Tod,1,0,19.4,2,1,0,amphibian,amphibian,amphibian,1,0 +2,Han,Solo,0,shortanswer,0,1,Tod,0,1,19.4,2,0,1,amphibian,amphibian,amphibian,0,0 +2,Han,Solo,0,shortanswer,1,0,Tony,0,0,19.4,2,1,0,insect,mammal,insect,0,0 +2,Han,Solo,0,shortanswer,0,1,Tony,0,0,19.4,2,0,1,insect,mammal,insect,0,0 +2,Han,Solo,0,shortanswer,1,0,Sharon,0,0,19.4,2,1,0,amphibian,insect,mammal,0,0 +2,Han,Solo,0,shortanswer,0,0,Sharon,0,0,19.4,2,0,1,amphibian,insect,mammal,0,0 +2,Han,Solo,0,shortanswer,0,0,Sharon,0,0,19.4,2,1,0,amphibian,insect,mammal,0,0 +2,Han,Solo,1,shortanswer,0,0,Sharon,0,0,19.4,2,0,0,amphibian,insect,mammal,0,0 +3,Yoda,Wise He Is,0,shortanswer,1,0,snake,1,0,7,9,1,0,amphibian,insect,amphibian,0,1 +3,Yoda,Wise He Is,0,shortanswer,0,1,snake,0,0,7,9,0,1,amphibian,insect,amphibian,0,1 +3,Yoda,Wise He Is,0,shortanswer,1,0,snake,0,0,7,9,1,0,amphibian,insect,amphibian,0,1 +3,Yoda,Wise He Is,0,shortanswer,0,1,snake,0,0,7,9,0,1,amphibian,insect,amphibian,0,1 +3,Yoda,Wise He Is,0,shortanswer,1,0,snakes,0,0,7,9,1,0,amphibian,insect,amphibian,0,1 +3,Yoda,Wise He Is,0,shortanswer,0,1,snakes,0,0,7,9,0,1,amphibian,insect,amphibian,0,1 +3,Yoda,Wise He Is,0,shortanswer,1,0,Snakes,0,0,7,9,1,0,amphibian,mammal,amphibian,0,1 +3,Yoda,Wise He Is,0,shortanswer,0,1,Snakes,0,0,7,9,0,0,amphibian,mammal,amphibian,0,1 +3,Yoda,Wise He Is,1,shortanswer,1,0,SnakeS,0,0,7,9,0,0,amphibian,mammal,amphibian,0,1 +4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,1,0,amphibian,amphibian,amphibian,0,1 +4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,0,1,amphibian,amphibian,amphibian,0,1 +4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,1,0,insect,insect,insect,0,1 +4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,0,1,insect,insect,insect,0,1 +4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,1,0,insect,amphibian,amphibian,0,1 +4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,0,1,insect,amphibian,amphibian,0,1 +4,Herbert,Garrison,1,shortanswer,0,0,dog,0,0,9.4,6,1,0,mammal,insect,mammal,0,1 +5,Agent,Smith,0,numerical,1,0,3.1,1,0,x+y,2,0,0,amphibian,amphibian,mammal,0,1 +5,Agent,Smith,0,numerical,0,0,3.1,1,0,4.5,2,0,0,amphibian,amphibian,mammal,0,1 +5,Agent,Smith,1,numerical,0,0,3.1,0,0,4.5,2,0,0,amphibian,amphibian,mammal,0,1 +6,Agent,Smith,0,numerical,0,0,3.142,0,0,19.4,3,0,0,amphibian,insect,mammal,1,0 +6,Agent,Smith,0,numerical,1,0,3.14,1,0,19.4,3,1,0,amphibian,insect,mammal,0,0 +6,Agent,Smith,0,numerical,0,0,3.14,0,1,19.4,3,0,1,amphibian,insect,mammal,0,0 +6,Agent,Smith,1,numerical,0,0,3.14,1,0,3.3,3,1,0,insect,insect,amphibian,0,0 +7,Agent,Smith,0,numerical,1,0,3.14,1,0,19.3,4,0,0,insect,amphibian,insect,0,1 +7,Agent,Smith,1,numerical,0,0,3.14,0,0,19.3,4,0,0,insect,amphibian,insect,0,1 +9,Bebe,Stevens,0,shortanswer,1,0,goat,1,0,8.5,2,0,0,amphibian,amphibian,insect,0,1 +9,Bebe,Stevens,0,shortanswer,0,1,goat,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1 +9,Bebe,Stevens,0,shortanswer,1,0,Mexican burrowing caecilian,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1 +9,Bebe,Stevens,0,shortanswer,0,1,Mexican burrowing caecilian,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1 +9,Bebe,Stevens,0,shortanswer,1,0,Mexican burrowing caecilian,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1 +9,Bebe,Stevens,0,shortanswer,0,1,Mexican burrowing caecilian,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1 +9,Bebe,Stevens,0,shortanswer,1,0,newt,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1 +9,Bebe,Stevens,0,shortanswer,0,1,newt,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1 +9,Bebe,Stevens,1,shortanswer,1,0,human,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1 +10,Luke,Skywalker,0,numerical,1,0,2,1,0,555,10,1,0,amphibian,amphibian,amphibian,1,0 +10,Luke,Skywalker,0,numerical,0,1,2,0,1,555,10,0,1,amphibian,amphibian,amphibian,0,0 +10,Luke,Skywalker,0,numerical,1,0,20,1,0,44,10,1,0,amphibian,amphibian,amphibian,0,0 +10,Luke,Skywalker,0,numerical,0,1,20,0,1,44,10,0,1,amphibian,amphibian,amphibian,0,0 +10,Luke,Skywalker,0,numerical,1,0,34,1,0,22,10,1,0,amphibian,amphibian,amphibian,0,0 +10,Luke,Skywalker,0,numerical,0,0,34,0,1,22,10,0,1,amphibian,amphibian,amphibian,0,0 +10,Luke,Skywalker,0,numerical,0,0,34,1,0,11,10,1,0,amphibian,mammal,amphibian,0,0 +10,Luke,Skywalker,0,numerical,0,0,34,0,1,11,10,0,0,amphibian,mammal,amphibian,0,0 +10,Luke,Skywalker,1,numerical,0,0,34,1,0,12,10,0,0,amphibian,mammal,amphibian,0,0 +11,Luke,Skywalker,0,shortanswer,1,0,toad,1,0,23,1,1,0,insect,amphibian,amphibian,0,1 +11,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,23,1,0,0,insect,amphibian,amphibian,0,1 +11,Luke,Skywalker,0,shortanswer,0,0,toad,1,0,22,1,0,0,insect,amphibian,amphibian,0,1 +11,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,22,1,0,0,insect,amphibian,amphibian,0,1 +11,Luke,Skywalker,0,shortanswer,0,0,toad,1,0,21,1,0,0,insect,amphibian,amphibian,0,1 +11,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,21,1,0,0,insect,amphibian,amphibian,0,1 +11,Luke,Skywalker,0,shortanswer,0,0,toad,1,0,9,1,0,0,insect,amphibian,amphibian,0,1 +11,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,9,1,0,0,insect,amphibian,amphibian,0,1 +11,Luke,Skywalker,1,shortanswer,0,0,toad,1,0,9.9,1,0,0,insect,amphibian,amphibian,0,1 +12,Luke,Skywalker,0,shortanswer,1,0,toad,1,0,8,2,1,0,insect,insect,amphibian,0,1 +12,Luke,Skywalker,0,shortanswer,0,1,toad,0,1,8,2,0,0,insect,insect,amphibian,0,1 +12,Luke,Skywalker,0,shortanswer,1,0,toad,1,0,8,2,0,0,insect,insect,amphibian,0,1 +12,Luke,Skywalker,0,shortanswer,0,1,toad,0,1,8,2,0,0,insect,insect,amphibian,0,1 +12,Luke,Skywalker,0,shortanswer,1,0,toad,1,0,8,2,0,0,insect,insect,amphibian,0,1 +12,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,8,2,0,0,insect,insect,amphibian,0,1 +12,Luke,Skywalker,0,shortanswer,0,0,toad,1,0,8,2,0,0,insect,insect,amphibian,0,1 +12,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,8,2,0,0,insect,insect,amphibian,0,1 +12,Luke,Skywalker,1,shortanswer,0,0,toad,1,0,8.5,2,0,0,insect,insect,amphibian,0,1 +13,Leia,The Princess,0,shortanswer,0,0,eggs,0,0,19.4,4,1,0,amphibian,amphibian,,1,1 +13,Leia,The Princess,0,shortanswer,0,0,eggs,0,0,19.4,4,1,0,amphibian,amphibian,mammal,0,1 +13,Leia,The Princess,1,shortanswer,0,0,eggs,0,0,19.4,4,0,0,amphibian,amphibian,mammal,0,1 diff --git a/mod/quiz/report/statistics/tests/fixtures/steps02.csv b/mod/quiz/report/statistics/tests/fixtures/steps02.csv new file mode 100644 index 00000000000..89535d7fecd --- /dev/null +++ b/mod/quiz/report/statistics/tests/fixtures/steps02.csv @@ -0,0 +1,19 @@ +quizattempt,firstname,lastname,responses.1.0,responses.1.1,responses.1.2,responses.1.-submit,responses.1.-tryagain,finished +1,John,Jones,insect,insect,insect,1,0,0 +1,John,Jones,insect,insect,insect,0,1,0 +1,John,Jones,insect,insect,insect,1,0,0 +1,John,Jones,insect,insect,insect,0,1,0 +1,John,Jones,insect,insect,insect,1,0,0 +1,John,Jones,insect,insect,insect,0,1,0 +1,John,Jones,insect,insect,insect,1,0,0 +1,John,Jones,insect,insect,insect,0,1,0 +1,John,Jones,insect,insect,insect,1,0,1 +2,Mark,Jones,insect,amphibian,insect,1,0,0 +2,Mark,Jones,insect,amphibian,insect,0,1,0 +2,Mark,Jones,insect,insect,insect,1,0,0 +2,Mark,Jones,insect,insect,insect,0,1,0 +2,Mark,Jones,mammal,insect,insect,1,0,0 +2,Mark,Jones,mammal,insect,insect,0,1,0 +2,Mark,Jones,mammal,insect,insect,1,0,0 +2,Mark,Jones,mammal,insect,insect,0,1,0 +2,Mark,Jones,mammal,insect,insect,1,0,1 diff --git a/mod/quiz/report/statistics/tests/stats_from_steps_walkthrough_test.php b/mod/quiz/report/statistics/tests/stats_from_steps_walkthrough_test.php index 57b7dee343a..80fdd316945 100644 --- a/mod/quiz/report/statistics/tests/stats_from_steps_walkthrough_test.php +++ b/mod/quiz/report/statistics/tests/stats_from_steps_walkthrough_test.php @@ -17,6 +17,14 @@ /** * Quiz attempt walk through using data from csv file. * + * The quiz stats below and the question stats found in qstats00.csv were calculated independently in a spreadsheet which is + * available in open document or excel format here : + * https://github.com/jamiepratt/moodle-quiz-tools/tree/master/statsspreadsheet + * + * Similarly the question variant's stats in qstats00.csv are calculated in stats_for_variant_1.xls and stats_for_variant_8.xls + * The calculations in the spreadsheets are the same as for the other question stats but applied just to the attempts where the + * variants appeared. + * * @package quiz_statistics * @category phpunit * @copyright 2013 The Open University @@ -74,14 +82,20 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth $attemptids = $this->walkthrough_attempts($csvdata['steps']); - $this->check_attempts_results($csvdata['results'], $attemptids); + if (isset($csvdata['results'])) { + $this->check_attempts_results($csvdata['results'], $attemptids); + } $this->report = new quiz_statistics_report(); - $whichattempts = QUIZ_GRADEAVERAGE; + $whichattempts = QUIZ_GRADEAVERAGE; // All attempts. + $whichtries = question_attempt::ALL_TRIES; $groupstudents = array(); $questions = $this->report->load_and_initialise_questions_for_calculations($this->quiz); - list($quizstats, $questionstats) = - $this->report->get_all_stats_and_analysis($this->quiz, $whichattempts, $groupstudents, $questions); + list($quizstats, $questionstats) = $this->report->get_all_stats_and_analysis($this->quiz, + $whichattempts, + $whichtries, + $groupstudents, + $questions); $qubaids = quiz_statistics_qubaids_condition($this->quiz->id, $groupstudents, $whichattempts); @@ -94,21 +108,203 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth $qcalc = new \core_question\statistics\questions\calculator($questions); $this->assertTimeCurrent($qcalc->get_last_calculated_time($qubaids)); - $expectedvariantcounts = array(2 => array(1 => 6, - 4 => 4, - 5 => 3, - 6 => 4, - 7 => 2, - 8 => 5, - 10 => 1)); + if (isset($csvdata['responsecounts'])) { + $this->check_response_counts($csvdata['responsecounts'], $qubaids, $questions, $whichtries); + } + if (isset($csvdata['qstats'])) { + $this->check_question_stats($csvdata['qstats'], $questionstats); + } + if ($quizsettings['testnumber'] === '00') { + $this->check_variants_count_for_quiz_00($questions, $questionstats, $whichtries, $qubaids); + $this->check_quiz_stats_for_quiz_00($quizstats); + } + } + + /** + * Check actual question stats are the same as that found in csv file. + * + * @param $qstats PHPUnit_Extensions_Database_DataSet_ITable data from csv file. + * @param $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition Calculated stats. + */ + protected function check_question_stats($qstats, $questionstats) { + for ($rowno = 0; $rowno < $qstats->getRowCount(); $rowno++) { + $slotqstats = $qstats->getRow($rowno); + foreach ($slotqstats as $statname => $slotqstat) { + if (!in_array($statname, array('slot', 'subqname')) && $slotqstat !== '') { + $this->assert_stat_equals($slotqstat, + $questionstats, + $slotqstats['slot'], + $slotqstats['subqname'], + $slotqstats['variant'], + $statname); + } + } + // Check that sub-question boolean field is correctly set. + $this->assert_stat_equals(!empty($slotqstats['subqname']), + $questionstats, + $slotqstats['slot'], + $slotqstats['subqname'], + $slotqstats['variant'], + 'subquestion'); + } + } + + /** + * Check that the stat is as expected within a reasonable tolerance. + * + * @param float|string|bool $expected expected value of stat. + * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats + * @param int $slot + * @param string $subqname if empty string then not an item stat. + * @param int|string $variant if empty string then not a variantstat. + * @param string $statname + */ + protected function assert_stat_equals($expected, $questionstats, $slot, $subqname, $variant, $statname) { + + if ($variant === '' && $subqname === '') { + $actual = $questionstats->for_slot($slot)->{$statname}; + } else if ($subqname !== '') { + $actual = $questionstats->for_subq($this->randqids[$slot][$subqname])->{$statname}; + } else { + $actual = $questionstats->for_slot($slot, $variant)->{$statname}; + } + $message = "$statname for slot $slot"; + if ($expected === '**NULL**') { + $this->assertEquals(null, $actual, $message); + } else if (is_bool($expected)) { + $this->assertEquals($expected, $actual, $message); + } else if (is_numeric($expected)) { + switch ($statname) { + case 'covariance' : + case 'discriminationindex' : + case 'discriminativeefficiency' : + case 'effectiveweight' : + $precision = 1e-5; + break; + default : + $precision = 1e-6; + } + $delta = abs($expected) * $precision; + $this->assertEquals((float)$expected, $actual, $message, $delta); + } else { + $this->assertEquals($expected, $actual, $message); + } + } + + protected function assert_response_count_equals($question, $qubaids, $expected, $whichtries) { + $responesstats = new \core_question\statistics\responses\analyser($question); + $analysis = $responesstats->load_cached($qubaids, $whichtries); + if (!isset($expected['subpart'])) { + $subpart = 1; + } else { + $subpart = $expected['subpart']; + } + list($subpartid, $responseclassid) = $this->get_response_subpart_and_class_id($question, + $subpart, + $expected['modelresponse']); + + $subpartanalysis = $analysis->get_analysis_for_subpart($expected['variant'], $subpartid); + $responseclassanalysis = $subpartanalysis->get_response_class($responseclassid); + $actualresponsecounts = $responseclassanalysis->data_for_question_response_table('', ''); + + foreach ($actualresponsecounts as $actualresponsecount) { + if ($actualresponsecount->response == $expected['actualresponse'] || + count($actualresponsecounts) == 1) { + $i = 1; + $partofanalysis = " slot {$expected['slot']}, rand q '{$expected['randq']}', variant {$expected['variant']}, ". + "for expected model response {$expected['modelresponse']}, ". + "actual response {$expected['actualresponse']}"; + while (isset($expected['count'.$i])) { + if ($expected['count'.$i] != 0) { + $this->assertTrue(isset($actualresponsecount->trycount[$i]), + "There is no count at all for try $i on ".$partofanalysis); + $this->assertEquals($expected['count'.$i], $actualresponsecount->trycount[$i], + "Count for try $i on ".$partofanalysis); + } + $i++; + } + if (isset($expected['totalcount'])) { + $this->assertEquals($expected['totalcount'], $actualresponsecount->totalcount, + "Total count on ".$partofanalysis); + } + return; + } + } + throw new coding_exception("Expected response '{$expected['actualresponse']}' not found."); + } + + protected function get_response_subpart_and_class_id($question, $subpart, $modelresponse) { + $qtypeobj = question_bank::get_qtype($question->qtype, false); + $possibleresponses = $qtypeobj->get_possible_responses($question); + $possibleresponsesubpartids = array_keys($possibleresponses); + if (!isset($possibleresponsesubpartids[$subpart - 1])) { + throw new coding_exception("Subpart '{$subpart}' not found."); + } + $subpartid = $possibleresponsesubpartids[$subpart - 1]; + + if ($modelresponse == '[NO RESPONSE]') { + return array($subpartid, null); + + } else if ($modelresponse == '[NO MATCH]') { + return array($subpartid, 0); + } + + $modelresponses = array(); + foreach ($possibleresponses[$subpartid] as $responseclassid => $subpartpossibleresponse) { + $modelresponses[$responseclassid] = $subpartpossibleresponse->responseclass; + } + $this->assertContains($modelresponse, $modelresponses); + $responseclassid = array_search($modelresponse, $modelresponses); + return array($subpartid, $responseclassid); + } + + /** + * @param $responsecounts + * @param $qubaids + * @param $questions + * @param $whichtries + */ + protected function check_response_counts($responsecounts, $qubaids, $questions, $whichtries) { + for ($rowno = 0; $rowno < $responsecounts->getRowCount(); $rowno++) { + $expected = $responsecounts->getRow($rowno); + $defaultsforexpected = array('randq' => '', 'variant' => '1', 'subpart' => '1'); + foreach ($defaultsforexpected as $key => $expecteddefault) { + if (!isset($expected[$key])) { + $expected[$key] = $expecteddefault; + } + } + if ($expected['randq'] == '') { + $question = $questions[$expected['slot']]; + } else { + $qid = $this->randqids[$expected['slot']][$expected['randq']]; + $question = question_finder::get_instance()->load_question_data($qid); + } + $this->assert_response_count_equals($question, $qubaids, $expected, $whichtries); + } + } + + /** + * @param $questions + * @param $questionstats + * @param $whichtries + * @param $qubaids + */ + protected function check_variants_count_for_quiz_00($questions, $questionstats, $whichtries, $qubaids) { + $expectedvariantcounts = array(2 => array(1 => 6, + 4 => 4, + 5 => 3, + 6 => 4, + 7 => 2, + 8 => 5, + 10 => 1)); foreach ($questions as $slot => $question) { if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) { continue; } $responesstats = new \core_question\statistics\responses\analyser($question); - $this->assertTimeCurrent($responesstats->get_last_analysed_time($qubaids)); - $analysis = $responesstats->load_cached($qubaids); + $this->assertTimeCurrent($responesstats->get_last_analysed_time($qubaids, $whichtries)); + $analysis = $responesstats->load_cached($qubaids, $whichtries); $variantsnos = $analysis->get_variant_nos(); if (isset($expectedvariantcounts[$slot])) { // Compare contents, ignore ordering of array, using canonicalize parameter of assertEquals. @@ -132,7 +328,7 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth $classanalysis = $subpartanalysis->get_response_class($classid); $actualresponsecounts = $classanalysis->data_for_question_response_table('', ''); foreach ($actualresponsecounts as $actualresponsecount) { - $totalspervariantno[$subpartid][$variantno] += $actualresponsecount->count; + $totalspervariantno[$subpartid][$variantno] += $actualresponsecount->totalcount; } } } @@ -163,89 +359,7 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth } } } - for ($rowno = 0; $rowno < $csvdata['responsecounts']->getRowCount(); $rowno++) { - $responsecount = $csvdata['responsecounts']->getRow($rowno); - if ($responsecount['randq'] == '') { - $question = $questions[$responsecount['slot']]; - } else { - $qid = $this->randqids[$responsecount['slot']][$responsecount['randq']]; - $question = question_finder::get_instance()->load_question_data($qid); - } - $this->assert_response_count_equals($question, $qubaids, $responsecount); - } - // 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 - $quizstatsexpected = array( - 'median' => 4.5, - 'firstattemptsavg' => 4.617333332, - 'allattemptsavg' => 4.617333332, - 'firstattemptscount' => 25, - 'allattemptscount' => 25, - 'standarddeviation' => 0.8117265554, - 'skewness' => -0.092502502, - 'kurtosis' => -0.7073968557, - 'cic' => -87.2230935542, - 'errorratio' => 136.8294900795, - 'standarderror' => 1.1106813066 - ); - - foreach ($quizstatsexpected as $statname => $statvalue) { - $this->assertEquals($statvalue, $quizstats->$statname, $quizstats->$statname, abs($statvalue) * 1.5e-5); - } - - for ($rowno = 0; $rowno < $csvdata['qstats']->getRowCount(); $rowno++) { - $slotqstats = $csvdata['qstats']->getRow($rowno); - foreach ($slotqstats as $statname => $slotqstat) { - if ($statname !== 'slot') { - $this->assert_stat_equals($questionstats, $slotqstats['slot'], null, null, $statname, (float)$slotqstat); - } - } - } - - $itemstats = array('s' => 12, - 'effectiveweight' => null, - 'discriminationindex' => 35.803933, - 'discriminativeefficiency' => 39.39393939, - 'sd' => 0.514928651, - 'facility' => 0.583333333, - 'maxmark' => 1, - 'positions' => '1', - 'slot' => null, - 'subquestion' => true); - foreach ($itemstats as $statname => $expected) { - $this->assert_stat_equals($questionstats, 1, null, 'numerical', $statname, $expected); - } - - // These variant's stats are calculated in stats_for_variant_1.xls and stats_for_variant_8.xls - // The calculations in the spreadsheets are the same but applied just to the attempts where the variants appeared. - - $statsforslot2variants = array(1 => array('s' => 6, - 'effectiveweight' => null, - 'discriminationindex' => -10.5999788, - 'discriminativeefficiency' => -14.28571429, - 'sd' => 0.5477225575, - 'facility' => 0.50, - 'maxmark' => 1, - 'variant' => 1, - 'slot' => 2, - 'subquestion' => false), - 8 => array('s' => 5, - 'effectiveweight' => null, - 'discriminationindex' => -57.77466679, - 'discriminativeefficiency' => -71.05263241, - 'sd' => 0.547722558, - 'facility' => 0.40, - 'maxmark' => 1, - 'variant' => 8, - 'slot' => 2, - 'subquestion' => false)); - foreach ($statsforslot2variants as $variant => $stats) { - foreach ($stats as $statname => $expected) { - $this->assert_stat_equals($questionstats, 2, $variant, null, $statname, $expected); - } - } foreach ($expectedvariantcounts as $slot => $expectedvariantcount) { foreach ($expectedvariantcount as $variantno => $s) { $this->assertEquals($s, $questionstats->for_slot($slot, $variantno)->s); @@ -254,94 +368,26 @@ 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\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 + * @param $quizstats */ - protected function assert_stat_equals($questionstats, $slot, $variant, $subqname, $statname, $expected) { + protected function check_quiz_stats_for_quiz_00($quizstats) { + $quizstatsexpected = array( + 'median' => 4.5, + 'firstattemptsavg' => 4.617333332, + 'allattemptsavg' => 4.617333332, + 'firstattemptscount' => 25, + 'allattemptscount' => 25, + 'standarddeviation' => 0.8117265554, + 'skewness' => -0.092502502, + 'kurtosis' => -0.7073968557, + 'cic' => -87.2230935542, + 'errorratio' => 136.8294900795, + 'standarderror' => 1.1106813066 + ); - if ($variant === null && $subqname === null) { - $actual = $questionstats->for_slot($slot)->{$statname}; - } else if ($subqname !== null) { - $actual = $questionstats->for_subq($this->randqids[$slot][$subqname])->{$statname}; - } else { - $actual = $questionstats->for_slot($slot, $variant)->{$statname}; + foreach ($quizstatsexpected as $statname => $statvalue) { + $this->assertEquals($statvalue, $quizstats->$statname, $quizstats->$statname, abs($statvalue) * 1.5e-5); } - if (is_bool($expected) || is_string($expected)) { - $this->assertEquals($expected, $actual, "$statname for slot $slot"); - } else { - switch ($statname) { - case 'covariance' : - case 'discriminationindex' : - case 'discriminativeefficiency' : - case 'effectiveweight' : - $precision = 1e-5; - break; - default : - $precision = 1e-6; - } - $delta = abs($expected) * $precision; - $this->assertEquals(floatval($expected), $actual, "$statname for slot $slot", $delta); - } - } - - protected function assert_response_count_equals($question, $qubaids, $responsecount) { - $responesstats = new \core_question\statistics\responses\analyser($question); - $analysis = $responesstats->load_cached($qubaids); - if (!isset($responsecount['subpart'])) { - $subpart = 1; - } else { - $subpart = $responsecount['subpart']; - } - list($subpartid, $responseclassid) = $this->get_response_subpart_and_class_id($question, - $subpart, - $responsecount['modelresponse']); - - $subpartanalysis = $analysis->get_analysis_for_subpart($responsecount['variant'], $subpartid); - $responseclassanalysis = $subpartanalysis->get_response_class($responseclassid); - $actualresponsecounts = $responseclassanalysis->data_for_question_response_table('', ''); - if ($responsecount['modelresponse'] !== '[NO RESPONSE]') { - foreach ($actualresponsecounts as $actualresponsecount) { - if ($actualresponsecount->response == $responsecount['actualresponse']) { - $this->assertEquals($responsecount['count'], $actualresponsecount->count); - return; - } - } - throw new coding_exception("Actual response '{$responsecount['actualresponse']}' not found."); - } else { - $actualresponsecount = array_pop($actualresponsecounts); - $this->assertEquals($responsecount['count'], $actualresponsecount->count); - } - } - - protected function get_response_subpart_and_class_id($question, $subpart, $modelresponse) { - $qtypeobj = question_bank::get_qtype($question->qtype, false); - $possibleresponses = $qtypeobj->get_possible_responses($question); - $possibleresponsesubpartids = array_keys($possibleresponses); - if (!isset($possibleresponsesubpartids[$subpart - 1])) { - throw new coding_exception("Subpart '{$subpart}' not found."); - } - $subpartid = $possibleresponsesubpartids[$subpart - 1]; - - if ($modelresponse == '[NO RESPONSE]') { - return array($subpartid, null); - - } else if ($modelresponse == '[NO MATCH]') { - return array($subpartid, 0); - } - - $modelresponses = array(); - foreach ($possibleresponses[$subpartid] as $responseclassid => $subpartpossibleresponse) { - $modelresponses[$responseclassid] = $subpartpossibleresponse->responseclass; - } - $this->assertContains($modelresponse, $modelresponses); - $responseclassid = array_search($modelresponse, $modelresponses); - return array($subpartid, $responseclassid); } } diff --git a/mod/quiz/tests/attempt_walkthrough_from_csv_test.php b/mod/quiz/tests/attempt_walkthrough_from_csv_test.php index 5598b3d3867..364335ed6f0 100644 --- a/mod/quiz/tests/attempt_walkthrough_from_csv_test.php +++ b/mod/quiz/tests/attempt_walkthrough_from_csv_test.php @@ -183,7 +183,9 @@ class mod_quiz_attempt_walkthrough_from_csv_testcase extends advanced_testcase { $quizsettings = $quizzes->getRow($rowno); $dataset = array(); foreach ($this->files as $file) { - $dataset[$file] = $this->load_csv_data_file($file, $quizsettings['testnumber']); + if (file_exists($this->get_full_path_of_csv_file($file, $quizsettings['testnumber']))) { + $dataset[$file] = $this->load_csv_data_file($file, $quizsettings['testnumber']); + } } $datasets[] = array($quizsettings, $dataset); } diff --git a/question/behaviour/behaviourbase.php b/question/behaviour/behaviourbase.php index bd576c18fec..d690bdda77c 100644 --- a/question/behaviour/behaviourbase.php +++ b/question/behaviour/behaviourbase.php @@ -281,10 +281,70 @@ abstract class question_behaviour { } /** - * @return question_possible_response[] where keys are subpartid or an empty array if no classification is possible. + * Classify responses for this question into a number of sub parts and response classes as defined by + * {@link \question_type::get_possible_responses} for this question type. + * + * @param string $whichtries which tries to analyse for response analysis. Will be one of + * question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. + * Defaults to question_attempt::LAST_TRY. + * @return (question_classified_response|array)[] If $whichtries is question_attempt::FIRST_TRY or LAST_TRY index is subpartid + * and values are question_classified_response instances. + * If $whichtries is question_attempt::ALL_TRIES then first key is submitted response no + * and the second key is subpartid. */ - public function classify_response() { - return $this->question->classify_response($this->qa->get_last_qt_data()); + public function classify_response($whichtries = question_attempt::LAST_TRY) { + if ($whichtries == question_attempt::LAST_TRY) { + return $this->question->classify_response($this->qa->get_last_qt_data()); + } else { + $stepswithsubmit = $this->qa->get_steps_with_submitted_response_iterator(); + if ($whichtries == question_attempt::FIRST_TRY) { + return $this->question->classify_response($stepswithsubmit[1]->get_qt_data()); + } else { + $classifiedresponses = array(); + foreach ($stepswithsubmit as $submittedresponseno => $step) { + $classifiedresponses[$submittedresponseno] = $this->question->classify_response($step->get_qt_data()); + } + return $this->remove_repeated_submitted_responses($classifiedresponses); + } + } + } + + /** + * Filter classified responses for multiple tries to remove identical responses that are not significant. + * + * In base class we compare last response to preceding responses and remove all identical responses until a different one is + * found then for that different response compare to preceding and remove all identical until ... + * + * @param array[] $classifiedresponses first index is submitted response no and second is sub-part id. Value is of type + * question_classified_response. Return value from self::classify_response for ALL_TRIES. + * @return array[] return non repeated responses. + */ + protected function remove_repeated_submitted_responses($classifiedresponses) { + $submittedresponsenos = array_keys($classifiedresponses); + $submittedresponsenos = array_reverse($submittedresponsenos); + $lastsubmittedresponseno = array_shift($submittedresponsenos); + $nooflastnewresponse = $lastsubmittedresponseno; + while (count($submittedresponsenos)) { + $precedingresponseno = array_shift($submittedresponsenos); + $responsesrepeated = true; + if (count($classifiedresponses[$precedingresponseno]) !== count($classifiedresponses[$nooflastnewresponse])) { + $responsesrepeated = false; + } else { + foreach (array_keys($classifiedresponses[$precedingresponseno]) as $subpartid) { + if ($classifiedresponses[$precedingresponseno][$subpartid] != + $classifiedresponses[$nooflastnewresponse][$subpartid]) { + $responsesrepeated = false; + break; + } + } + } + if ($responsesrepeated) { + unset($classifiedresponses[$precedingresponseno]); + } else { + $nooflastnewresponse = $precedingresponseno; + } + } + return $classifiedresponses; } /** diff --git a/question/behaviour/interactivecountback/behaviour.php b/question/behaviour/interactivecountback/behaviour.php index 20c650b6187..81088c67736 100644 --- a/question/behaviour/interactivecountback/behaviour.php +++ b/question/behaviour/interactivecountback/behaviour.php @@ -90,4 +90,31 @@ class qbehaviour_interactivecountback extends qbehaviour_interactive { return $this->question->compute_final_grade($responses, $totaltries); } + + /** + * Filter classified responses for multiple tries to remove identical responses that are not significant. + * + * For this behaviour the significant repeated response part are the first of any repeated responses, for any part of the + * question. These are the responses that are graded. + * + * @param array[] $classifiedresponses first index is submitted response no and second is sub-part id. Value is of type + * question_classified_response. Return value from self::classify_response for ALL_TRIES. + * @return array[] return non repeated responses. + */ + protected function remove_repeated_submitted_responses($classifiedresponses) { + $subpartids = array_keys($classifiedresponses[1]); + foreach ($subpartids as $subpartid) { + $lastdifferentresponsepart = 1; + $tryno = 2; + while (isset($classifiedresponses[$tryno])) { + if ($classifiedresponses[$tryno][$subpartid] != $classifiedresponses[$lastdifferentresponsepart][$subpartid]) { + $lastdifferentresponsepart = $tryno; + } else { + unset($classifiedresponses[$tryno][$subpartid]); + } + $tryno++; + } + } + return $classifiedresponses; + } } diff --git a/question/classes/statistics/responses/analyser.php b/question/classes/statistics/responses/analyser.php index 96fdc4178a8..594ce6fe464 100644 --- a/question/classes/statistics/responses/analyser.php +++ b/question/classes/statistics/responses/analyser.php @@ -15,11 +15,10 @@ // along with Moodle. If not, see . /** - * This file contains the code to analyse all the responses to a particular - * question. + * This file contains the code to analyse all the responses to a particular question. * * @package core_question - * @copyright 2013 Open University + * @copyright 2014 Open University * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -28,19 +27,22 @@ namespace core_question\statistics\responses; defined('MOODLE_INTERNAL') || die(); /** - * This class can store and compute the analysis of the responses to a particular - * question. + * This class can compute, store and cache the analysis of the responses to a particular question. * - * @copyright 2013 Open University - * @author Jamie Pratt - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package core_question + * @copyright 2014 The Open University + * @author James Pratt me@jamiep.org + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analyser { + /** @var int Time after which responses are automatically reanalysed. */ + const TIME_TO_CACHE = 900; // 15 minutes. + /** @var object full question data from db. */ protected $questiondata; /** - * @var analysis_for_question + * @var analysis_for_question|analysis_for_question_all_tries */ public $analysis; @@ -63,15 +65,23 @@ class analyser { * for a particular question. * * @param object $questiondata the full question data from the database defining this question. + * @param string $whichtries which tries to analyse. */ - public function __construct($questiondata) { + public function __construct($questiondata, $whichtries = \question_attempt::LAST_TRY) { $this->questiondata = $questiondata; $qtypeobj = \question_bank::get_qtype($this->questiondata->qtype); - $this->analysis = new analysis_for_question($qtypeobj->get_possible_responses($this->questiondata)); + if ($whichtries != \question_attempt::ALL_TRIES) { + $this->analysis = new analysis_for_question($qtypeobj->get_possible_responses($this->questiondata)); + } else { + $this->analysis = new analysis_for_question_all_tries($qtypeobj->get_possible_responses($this->questiondata)); + } + $this->breakdownbyvariant = $qtypeobj->break_down_stats_and_response_analysis_by_variant($this->questiondata); } /** + * Does the computed analysis have sub parts? + * * @return bool whether this analysis has more than one subpart. */ public function has_subparts() { @@ -79,6 +89,8 @@ class analyser { } /** + * Does the computed analysis's sub parts have classes? + * * @return bool whether this analysis has (a subpart with) more than one response class. */ public function has_response_classes() { @@ -91,40 +103,21 @@ class analyser { } /** - * @return bool whether this analysis has a response class more than one - * different acutal response, or if the actual response is different from - * the model response. - */ - public function has_actual_responses() { - foreach ($this->responseclasses as $subpartid => $partclasses) { - foreach ($partclasses as $responseclassid => $modelresponse) { - $numresponses = count($this->responses[$subpartid][$responseclassid]); - if ($numresponses > 1) { - return true; - } - $actualresponse = key($this->responses[$subpartid][$responseclassid]); - if ($numresponses == 1 && $actualresponse != $modelresponse->responseclass) { - return true; - } - } - } - return false; - } - - /** - * Analyse all the response data for for all the specified attempts at - * this question. + * Analyse all the response data for for all the specified attempts at this question. + * * @param \qubaid_condition $qubaids which attempts to consider. + * @param string $whichtries which tries to analyse. Will be one of + * \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. * @return analysis_for_question */ - public function calculate($qubaids) { + public function calculate($qubaids, $whichtries = \question_attempt::LAST_TRY) { // Load data. $dm = new \question_engine_data_mapper(); $questionattempts = $dm->load_attempts_at_question($this->questiondata->id, $qubaids); // Analyse it. foreach ($questionattempts as $qa) { - $responseparts = $qa->classify_response(); + $responseparts = $qa->classify_response($whichtries); if ($this->breakdownbyvariant) { $this->analysis->count_response_parts($qa->get_variant(), $responseparts); } else { @@ -132,33 +125,44 @@ class analyser { } } - $this->analysis->cache($qubaids, $this->questiondata->id); + $this->analysis->cache($qubaids, $whichtries, $this->questiondata->id); return $this->analysis; } - /** @var integer Time after which responses are automatically reanalysed. */ - const TIME_TO_CACHE = 900; // 15 minutes. - - /** * Retrieve the computed response analysis from the question_response_analysis table. * - * @param \qubaid_condition $qubaids which attempts to get cached response analysis for. + * @param \qubaid_condition $qubaids load the analysis of which question usages? + * @param string $whichtries load the analysis of which tries? * @return analysis_for_question|boolean analysis or false if no cached analysis found. */ - public function load_cached($qubaids) { + public function load_cached($qubaids, $whichtries) { global $DB; $timemodified = time() - self::TIME_TO_CACHE; - $rows = $DB->get_records_select('question_response_analysis', 'hashcode = ? AND questionid = ? AND timemodified > ?', - array($qubaids->get_hash_code(), $this->questiondata->id, $timemodified)); - if (!$rows) { + // Variable name 'analyses' is the plural of 'analysis'. + $responseanalyses = $DB->get_records_select('question_response_analysis', + 'hashcode = ? AND whichtries = ? AND questionid = ? AND timemodified > ?', + array($qubaids->get_hash_code(), $whichtries, $this->questiondata->id, $timemodified)); + if (!$responseanalyses) { return false; } - foreach ($rows as $row) { - $class = $this->analysis->get_analysis_for_subpart($row->variant, $row->subqid)->get_response_class($row->aid); - $class->add_response_and_count($row->response, $row->credit, $row->rcount); + $analysisids = array(); + foreach ($responseanalyses as $responseanalysis) { + $analysisforsubpart = $this->analysis->get_analysis_for_subpart($responseanalysis->variant, $responseanalysis->subqid); + $class = $analysisforsubpart->get_response_class($responseanalysis->aid); + $class->add_response($responseanalysis->response, $responseanalysis->credit); + $analysisids[] = $responseanalysis->id; + } + list($sql, $params) = $DB->get_in_or_equal($analysisids); + $counts = $DB->get_records_select('question_response_count', "analysisid {$sql}", $params); + foreach ($counts as $count) { + $responseanalysis = $responseanalyses[$count->analysisid]; + $analysisforsubpart = $this->analysis->get_analysis_for_subpart($responseanalysis->variant, $responseanalysis->subqid); + $class = $analysisforsubpart->get_response_class($responseanalysis->aid); + $class->set_response_count($responseanalysis->response, $count->try, $count->rcount); + } return $this->analysis; } @@ -167,15 +171,17 @@ class analyser { /** * Find time of non-expired analysis in the database. * - * @param $qubaids \qubaid_condition + * @param \qubaid_condition $qubaids check for the analysis of which question usages? + * @param string $whichtries check for the analysis of which tries? * @return integer|boolean Time of cached record that matches this qubaid_condition or false if none found. */ - public function get_last_analysed_time($qubaids) { + public function get_last_analysed_time($qubaids, $whichtries) { global $DB; $timemodified = time() - self::TIME_TO_CACHE; return $DB->get_field_select('question_response_analysis', 'timemodified', - 'hashcode = ? AND questionid = ? AND timemodified > ?', - array($qubaids->get_hash_code(), $this->questiondata->id, $timemodified), IGNORE_MULTIPLE); + 'hashcode = ? AND whichtries = ? AND questionid = ? AND timemodified > ?', + array($qubaids->get_hash_code(), $whichtries, $this->questiondata->id, $timemodified), + IGNORE_MULTIPLE); } } diff --git a/question/classes/statistics/responses/analysis_for_actual_response.php b/question/classes/statistics/responses/analysis_for_actual_response.php index a491777c359..56ca962ed4d 100644 --- a/question/classes/statistics/responses/analysis_for_actual_response.php +++ b/question/classes/statistics/responses/analysis_for_actual_response.php @@ -23,12 +23,35 @@ namespace core_question\statistics\responses; - +/** + * The leafs of the analysis data structure. + * + * - There is a separate data structure for each question or sub question's analysis + * {@link \core_question\statistics\responses\analysis_for_question} + * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. + * - There are separate analysis for each variant in this top level instance. + * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. + * {@link \core_question\statistics\responses\analysis_for_subpart}. + * - Then within the sub part analysis there are response class analysis + * {@link \core_question\statistics\responses\analysis_for_class}. + * - Then within each class analysis there are analysis for each actual response + * {@link \core_question\statistics\responses\analysis_for_actual_response}. + * + * @package core_question + * @copyright 2014 The Open University + * @author James Pratt me@jamiep.org + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ class analysis_for_actual_response { /** - * @var int count of this response + * @var int[] count per try for this response. */ - protected $count; + protected $trycount = array(); + + /** + * @var int total count of tries with this response. + */ + protected $totalcount = 0; /** * @var float grade for this response, normally between 0 and 1. @@ -43,32 +66,54 @@ class analysis_for_actual_response { /** * @param string $response * @param float $fraction - * @param int $count defaults to zero, this param used when loading from db. */ - public function __construct($response, $fraction, $count = 0) { + public function __construct($response, $fraction) { $this->response = $response; $this->fraction = $fraction; - $this->count = $count; } /** * Used to count the occurrences of response sub parts. + * + * @param int $try the try number, or 0 if only keeping one count, not a count for each try. */ - public function increment_count() { - $this->count++; + public function increment_count($try = 0) { + $this->totalcount++; + if ($try != 0) { + if (!isset($this->trycount[$try])) { + $this->trycount[$try] = 0; + } + $this->trycount[$try]++; + } + } /** - * @param \qubaid_condition $qubaids - * @param int $questionid the question id - * @param int $variantno - * @param string $subpartid - * @param string $responseclassid + * Used to set the count of occurrences of response sub parts, when loading count from cache. + * + * @param int $try the try number, or 0 if only keeping one count, not a count for each try. + * @param int $count */ - public function cache($qubaids, $questionid, $variantno, $subpartid, $responseclassid) { + public function set_count($try, $count) { + $this->totalcount = $this->totalcount + $count; + $this->trycount[$try] = $count; + } + + /** + * Cache analysis for class. + * + * @param \qubaid_condition $qubaids which question usages have been analysed. + * @param string $whichtries which tries have been analysed? + * @param int $questionid which question. + * @param int $variantno which variant. + * @param string $subpartid which sub part is this actual response in? + * @param string $responseclassid which response class is this actual response in? + */ + public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $responseclassid) { global $DB; $row = new \stdClass(); $row->hashcode = $qubaids->get_hash_code(); + $row->whichtries = $whichtries; $row->questionid = $questionid; $row->variant = $variantno; $row->subqid = $subpartid; @@ -78,17 +123,29 @@ class analysis_for_actual_response { $row->aid = $responseclassid; } $row->response = $this->response; - $row->rcount = $this->count; $row->credit = $this->fraction; $row->timemodified = time(); - $DB->insert_record('question_response_analysis', $row, false); - } - - public function response_matches($response) { - return $response == $this->response; + $analysisid = $DB->insert_record('question_response_analysis', $row); + if ($whichtries === \question_attempt::ALL_TRIES) { + foreach ($this->trycount as $try => $count) { + $countrow = new \stdClass(); + $countrow->try = $try; + $countrow->rcount = $count; + $countrow->analysisid = $analysisid; + $DB->insert_record('question_response_count', $countrow, false); + } + } else { + $countrow = new \stdClass(); + $countrow->try = 0; + $countrow->rcount = $this->totalcount; + $countrow->analysisid = $analysisid; + $DB->insert_record('question_response_count', $countrow, false); + } } /** + * Returns an object with a property for each column of the question response analysis table. + * * @param string $partid * @param string $modelresponse * @return object @@ -99,7 +156,17 @@ class analysis_for_actual_response { $rowdata->responseclass = $modelresponse; $rowdata->response = $this->response; $rowdata->fraction = $this->fraction; - $rowdata->count = $this->count; + $rowdata->totalcount = $this->totalcount; + $rowdata->trycount = $this->trycount; return $rowdata; } + + /** + * What is the highest try number that this response has been seen? + * + * @return int try number + */ + public function get_maximum_tries() { + return max(array_keys($this->trycount)); + } } diff --git a/question/classes/statistics/responses/analysis_for_class.php b/question/classes/statistics/responses/analysis_for_class.php index 8f43ee7af17..42c4aff5249 100644 --- a/question/classes/statistics/responses/analysis_for_class.php +++ b/question/classes/statistics/responses/analysis_for_class.php @@ -26,39 +26,48 @@ namespace core_question\statistics\responses; /** - * Represents an actual part of the response that has been classified in a class of responses for this sub part of the question. - * - * A question and it's response is represented as having one or more sub parts where the response to each sub-part might fall - * into one of one or more classes. + * Counts a class of responses for this sub part of the question. * * No response is one possible class of response to a question. * - * @copyright 2010 The Open University + * - There is a separate data structure for each question or sub question's analysis + * {@link \core_question\statistics\responses\analysis_for_question} + * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. + * - There are separate analysis for each variant in this top level instance. + * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. + * {@link \core_question\statistics\responses\analysis_for_subpart}. + * - Then within the sub part analysis there are response class analysis + * {@link \core_question\statistics\responses\analysis_for_class}. + * - Then within each class analysis there are analysis for each actual response + * {@link \core_question\statistics\responses\analysis_for_actual_response}. + * + * @package core_question + * @copyright 2014 The Open University + * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_class { /** - * @var string + * @var string must be unique for each response class within this sub part. */ protected $responseclassid; /** - * @var string + * @var string represent this class in the response analysis table. */ protected $modelresponse; /** @var string the (partial) credit awarded for this responses. */ protected $fraction; - /** - * - * @var analysis_for_actual_response[] key is the actual response represented as a string as it will be displayed in report. + /** @var analysis_for_actual_response[] key is the actual response represented as a string as it will be displayed in report. */ protected $actualresponses = array(); /** * Constructor, just an easy way to set the fields. + * * @param \question_possible_response $possibleresponse * @param string $responseclassid */ @@ -69,56 +78,88 @@ class analysis_for_class { } /** - * @param string $actualresponse + * Keep a count of a response to this question sub part that falls within this class. + * + * @param string $actualresponse * @param float|null $fraction + * @param int $try + * @return \core_question\statistics\responses\analysis_for_actual_response */ - public function count_response($actualresponse, $fraction) { + public function count_response($actualresponse, $fraction, $try) { if (!isset($this->actualresponses[$actualresponse])) { if ($fraction === null) { $fraction = $this->fraction; } - $this->actualresponses[$actualresponse] = new analysis_for_actual_response($actualresponse, $fraction); + $this->add_response($actualresponse, $fraction); } - $this->actualresponses[$actualresponse]->increment_count(); + $this->get_response($actualresponse)->increment_count($try); } /** - * @param \qubaid_condition $qubaids - * @param int $questionid the question id - * @param int $variantno - * @param string $subpartid + * Cache analysis for class. + * + * @param \qubaid_condition $qubaids which question usages have been analysed. + * @param string $whichtries which tries have been analysed? + * @param int $questionid which question. + * @param int $variantno which variant. + * @param string $subpartid which sub part. */ - public function cache($qubaids, $questionid, $variantno, $subpartid) { - foreach ($this->actualresponses as $response => $actualresponse) { - $actualresponse->cache($qubaids, $questionid, $variantno, $subpartid, $this->responseclassid, $response); + public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid) { + foreach ($this->get_responses() as $response) { + $analysisforactualresponse = $this->get_response($response); + $analysisforactualresponse->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $this->responseclassid); } } - public function add_response_and_count($response, $fraction, $count) { - $this->actualresponses[$response] = new analysis_for_actual_response($response, $fraction, $count); + /** + * Add an actual response to the data structure. + * + * @param string $response A string representing the actual response. + * @param float $fraction The fraction of grade awarded for this response. + */ + public function add_response($response, $fraction) { + $this->actualresponses[$response] = new analysis_for_actual_response($response, $fraction); } /** + * Used when loading cached counts. + * + * @param string $response + * @param int $try the try number, will be zero if not keeping track of try. + * @param int $count the count + */ + public function set_response_count($response, $try, $count) { + $this->actualresponses[$response]->set_count($try, $count); + } + + /** + * Are there actual responses to sub parts that where classified into this class? + * * @return bool whether this analysis has a response class with more than one * different actual response, or if the actual response is different from * the model response. */ public function has_actual_responses() { - if (count($this->actualresponses) > 1) { + $actualresponses = $this->get_responses(); + if (count($actualresponses) > 1) { return true; - } else if (count($this->actualresponses) == 1) { - $onlyactualresponse = reset($this->actualresponses); - return !$onlyactualresponse->response_matches($this->modelresponse); + } else if (count($actualresponses) === 1) { + $singleactualresponse = reset($actualresponses); + return $singleactualresponse != $this->modelresponse; } return false; } /** + * Return the data to display in the response analysis table. + * + * @param bool $responseclasscolumn + * @param string $partid * @return object[] */ public function data_for_question_response_table($responseclasscolumn, $partid) { $return = array(); - if (empty($this->actualresponses)) { + if (count($this->get_responses()) == 0) { $rowdata = new \stdClass(); $rowdata->part = $partid; $rowdata->responseclass = $this->modelresponse; @@ -128,13 +169,47 @@ class analysis_for_class { $rowdata->response = ''; } $rowdata->fraction = $this->fraction; - $rowdata->count = 0; + $rowdata->totalcount = 0; + $rowdata->trycount = array(); $return[] = $rowdata; } else { - foreach ($this->actualresponses as $actualresponse) { - $return[] = $actualresponse->data_for_question_response_table($partid, $this->modelresponse); + foreach ($this->get_responses() as $actualresponse) { + $response = $this->get_response($actualresponse); + $return[] = $response->data_for_question_response_table($partid, $this->modelresponse); } } return $return; } + + /** + * What is the highest try number that an actual response of this response class has been seen? + * + * @return int try number + */ + public function get_maximum_tries() { + $max = 1; + foreach ($this->get_responses() as $actualresponse) { + $max = max($max, $this->get_response($actualresponse)->get_maximum_tries()); + } + return $max; + } + + /** + * Return array of the actual responses to this sub part that were classified into this class. + * + * @return string[] the actual responses we are counting tries at. + */ + protected function get_responses() { + return array_keys($this->actualresponses); + } + + /** + * Get the data structure used to count the responses that match an actual response within this class of responses. + * + * @param string $response + * @return analysis_for_actual_response the instance for keeping count of tries for $response. + */ + protected function get_response($response) { + return $this->actualresponses[$response]; + } } diff --git a/question/classes/statistics/responses/analysis_for_question.php b/question/classes/statistics/responses/analysis_for_question.php index 0fd1de6f9c3..5be3e4213f8 100644 --- a/question/classes/statistics/responses/analysis_for_question.php +++ b/question/classes/statistics/responses/analysis_for_question.php @@ -29,17 +29,29 @@ defined('MOODLE_INTERNAL') || die(); /** * Analysis for possible responses for parts of a question. It is up to a question type designer to decide on how many parts their - * question has. A sub part might represent a sub question embedded in the question for example in a matching question there are + * question has. See {@link \question_type::get_possible_responses()} and sub classes where the sub parts and response classes are + * defined. + * + * A sub part might represent a sub question embedded in the question for example in a matching question there are * several sub parts. A numeric question with a unit might be divided into two sub parts for the purposes of response analysis * or the question type designer might decide to treat the answer, both the numeric and unit part, * as a whole for the purposes of response analysis. * - * Responses can be further divided into 'classes' in which they are classified. One or more of these 'classes' are contained in - * the responses + * - There is a separate data structure for each question or sub question's analysis + * {@link \core_question\statistics\responses\analysis_for_question} + * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. + * - There are separate analysis for each variant in this top level instance. + * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. + * {@link \core_question\statistics\responses\analysis_for_subpart}. + * - Then within the sub part analysis there are response class analysis + * {@link \core_question\statistics\responses\analysis_for_class}. + * - Then within each class analysis there are analysis for each actual response + * {@link \core_question\statistics\responses\analysis_for_actual_response}. * - * @copyright 2013 Open University - * @author Jamie Pratt - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package core_question + * @copyright 2014 The Open University + * @author James Pratt me@jamiep.org + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_question { @@ -149,6 +161,23 @@ class analysis_for_question { return false; } + /** + * @return bool Does this response analysis include counts for responses for multiple tries of the question? + */ + public function has_multiple_tries_data() { + return false; + } + + /** + * What is the highest number of tries at this question? + * + * @return int always 1 as this class is for analysing only one try. + */ + public function get_maximum_tries() { + return 1; + } + + /** * Takes an array of {@link \question_classified_response} and adds counts of the responses to the sub parts and classes. * @@ -162,13 +191,15 @@ class analysis_for_question { } /** - * @param \qubaid_condition $qubaids - * @param int $questionid the question id + * @param \qubaid_condition $qubaids which question usages have been analysed. + * @param string $whichtries which tries have been analysed? + * @param int $questionid which question. */ - public function cache($qubaids, $questionid) { + public function cache($qubaids, $whichtries, $questionid) { foreach ($this->get_variant_nos() as $variantno) { foreach ($this->get_subpart_ids($variantno) as $subpartid) { - $this->get_analysis_for_subpart($variantno, $subpartid)->cache($qubaids, $questionid, $variantno, $subpartid); + $analysisforsubpart = $this->get_analysis_for_subpart($variantno, $subpartid); + $analysisforsubpart->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid); } } } diff --git a/question/classes/statistics/responses/analysis_for_question_all_tries.php b/question/classes/statistics/responses/analysis_for_question_all_tries.php new file mode 100644 index 00000000000..86deddf0841 --- /dev/null +++ b/question/classes/statistics/responses/analysis_for_question_all_tries.php @@ -0,0 +1,84 @@ +. + +/** + * This file contains a class to analyse all the responses for multiple tries at a particular question. + * + * @package core_question + * @copyright 2014 Open University + * @author Jamie Pratt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\statistics\responses; + +/** + * Analysis for possible responses for parts of a question with multiple submitted responses. + * + * If the analysis was for a single try it would be handled by {@link \core_question\statistics\responses\analysis_for_question}. + * + * - There is a separate data structure for each question or sub question's analysis + * {@link \core_question\statistics\responses\analysis_for_question} + * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. + * - There are separate analysis for each variant in this top level instance. + * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. + * {@link \core_question\statistics\responses\analysis_for_subpart}. + * - Then within the sub part analysis there are response class analysis + * {@link \core_question\statistics\responses\analysis_for_class}. + * - Then within each class analysis there are analysis for each actual response + * {@link \core_question\statistics\responses\analysis_for_actual_response}. + * + * @package core_question + * @copyright 2014 The Open University + * @author James Pratt me@jamiep.org + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class analysis_for_question_all_tries extends analysis_for_question{ + /** + * Constructor. + * + * @param int $variantno variant number + * @param \array[] $responsepartsforeachtry for question with multiple tries we expect an array with first index being try no + * then second index is subpartid and values are \question_classified_response + */ + public function count_response_parts($variantno, $responsepartsforeachtry) { + foreach ($responsepartsforeachtry as $try => $responseparts) { + foreach ($responseparts as $subpartid => $responsepart) { + $this->get_analysis_for_subpart($variantno, $subpartid)->count_response($responsepart, $try); + } + } + } + + public function has_multiple_tries_data() { + return true; + } + + /** + * What is the highest number of tries at this question? + * + * @return int try number + */ + public function get_maximum_tries() { + $max = 1; + foreach ($this->get_variant_nos() as $variantno) { + foreach ($this->get_subpart_ids($variantno) as $subpartid) { + $max = max($max, $this->get_analysis_for_subpart($variantno, $subpartid)->get_maximum_tries()); + } + } + return $max; + } + +} diff --git a/question/classes/statistics/responses/analysis_for_subpart.php b/question/classes/statistics/responses/analysis_for_subpart.php index 53d99133206..a9e8a868067 100644 --- a/question/classes/statistics/responses/analysis_for_subpart.php +++ b/question/classes/statistics/responses/analysis_for_subpart.php @@ -16,24 +16,41 @@ /** * - * 'Classes' to classify the sub parts of a question response into. + * Data structure to count responses for each of the sub parts of a question. * * @package core_question - * @copyright 2013 The Open University + * @copyright 2014 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\responses; - +/** + * Representing the analysis of each of the sub parts of each variant of the question. + * + * - There is a separate data structure for each question or sub question's analysis + * {@link \core_question\statistics\responses\analysis_for_question} + * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. + * - There are separate analysis for each variant in this top level instance. + * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. + * {@link \core_question\statistics\responses\analysis_for_subpart}. + * - Then within the sub part analysis there are response class analysis + * {@link \core_question\statistics\responses\analysis_for_class}. + * - Then within each class analysis there are analysis for each actual response + * {@link \core_question\statistics\responses\analysis_for_actual_response}. + * + * @package core_question + * @copyright 2014 The Open University + * @author James Pratt me@jamiep.org + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ class analysis_for_subpart { /** - * Takes an array of possible_responses - ({@link \question_possible_response} objects). - * Or takes an array of {@link \question_possible_response} objects. + * Takes an array of possible_responses as returned from {@link \question_type::get_possible_responses()}. * - * @param \question_possible_response[] $responseclasses + * @param \question_possible_response[] $responseclasses as returned from {@link \question_type::get_possible_responses()}. */ public function __construct(array $responseclasses = null) { if (is_array($responseclasses)) { @@ -44,7 +61,6 @@ class analysis_for_subpart { } /** - * * @var analysis_for_class[] */ protected $responseclasses; @@ -59,6 +75,8 @@ class analysis_for_subpart { } /** + * Get the instance of the class handling the analysis of $classid for this sub part. + * * @param string $classid id for response class. * @return analysis_for_class */ @@ -66,40 +84,68 @@ class analysis_for_subpart { return $this->responseclasses[$classid]; } + /** + * Whether there is more than one response class for responses in this question sub part? + * + * @return bool Are there? + */ public function has_multiple_response_classes() { - return count($this->responseclasses) > 1; + return count($this->get_response_class_ids()) > 1; } /** + * Count a part of a response. + * * @param \question_classified_response $subpart + * @param int $try the try number or zero if not keeping track of try number */ - public function count_response($subpart) { - $this->responseclasses[$subpart->responseclassid]->count_response($subpart->response, $subpart->fraction); + public function count_response($subpart, $try = 0) { + $responseanalysisforclass = $this->get_response_class($subpart->responseclassid); + $responseanalysisforclass->count_response($subpart->response, $subpart->fraction, $try); } /** - * @param \qubaid_condition $qubaids - * @param int $questionid the question id - * @param int $variantno - * @param string $subpartid + * Cache analysis for sub part. + * + * @param \qubaid_condition $qubaids which question usages have been analysed. + * @param string $whichtries which tries have been analysed? + * @param int $questionid which question. + * @param int $variantno which variant. + * @param string $subpartid which sub part. */ - public function cache($qubaids, $questionid, $variantno, $subpartid) { - foreach ($this->responseclasses as $responseclassid => $responseclass) { - $responseclass->cache($qubaids, $questionid, $variantno, $subpartid, $responseclassid); + public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid) { + foreach ($this->get_response_class_ids() as $responseclassid) { + $analysisforclass = $this->get_response_class($responseclassid); + $analysisforclass->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $responseclassid); } } /** + * Has actual responses different to the model response for this class? + * * @return bool whether this analysis has a response class with more than one * different actual response, or if the actual response is different from * the model response. */ public function has_actual_responses() { - foreach ($this->responseclasses as $responseclass) { - if ($responseclass->has_actual_responses()) { + foreach ($this->get_response_class_ids() as $responseclassid) { + if ($this->get_response_class($responseclassid)->has_actual_responses()) { return true; } } return false; } + + /** + * What is the highest try number for this sub part? + * + * @return int max tries + */ + public function get_maximum_tries() { + $max = 1; + foreach ($this->get_response_class_ids() as $responseclassid) { + $max = max($max, $this->get_response_class($responseclassid)->get_maximum_tries()); + } + return $max; + } } diff --git a/question/engine/datalib.php b/question/engine/datalib.php index c000b20b887..7b0685c2157 100644 --- a/question/engine/datalib.php +++ b/question/engine/datalib.php @@ -691,10 +691,11 @@ ORDER BY qa.slot /** * Load a {@link question_attempt} from the database, including all its * steps. + * * @param int $questionid the question to load all the attempts fors. * @param qubaid_condition $qubaids used to restrict which usages are included * in the query. See {@link qubaid_condition}. - * @return array of question_attempts. + * @return question_attempt[] array of question_attempts that were loaded. */ public function load_attempts_at_question($questionid, qubaid_condition $qubaids) { $sql = " diff --git a/question/engine/questionattempt.php b/question/engine/questionattempt.php index 7b33bf60b53..bce04651e8d 100644 --- a/question/engine/questionattempt.php +++ b/question/engine/questionattempt.php @@ -1321,14 +1321,19 @@ class question_attempt { } /** - * Break down a student response by sub part and classification. - * See also {@link question_type::get_possible_responses()} + * Break down a student response by sub part and classification. See also {@link question::classify_response}. * Used for response analysis. * - * @return question_possible_response[] where keys are subpartid. + * @param string $whichtries which tries to analyse for response analysis. Will be one of + * question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. + * Defaults to question_attempt::LAST_TRY. + * @return (question_classified_response|array)[] If $whichtries is question_attempt::FIRST_TRY or LAST_TRY index is subpartid + * and values are question_classified_response instances. + * If $whichtries is question_attempt::ALL_TRIES then first key is submitted response no + * and the second key is subpartid. */ - public function classify_response() { - return $this->behaviour->classify_response(); + public function classify_response($whichtries = self::LAST_TRY) { + return $this->behaviour->classify_response($whichtries); } /** @@ -1686,7 +1691,7 @@ class question_attempt_steps_with_submitted_response_iterator extends question_a } /** @return bool */ public function valid() { - return $this->offsetExists($this->submittedresponseno); + return $this->submittedresponseno >= 1 && $this->submittedresponseno <= count($this->stepswithsubmittedresponses); } /** diff --git a/question/engine/statisticslib.php b/question/engine/statisticslib.php index 363c1024a2e..9538346b58a 100644 --- a/question/engine/statisticslib.php +++ b/question/engine/statisticslib.php @@ -32,12 +32,19 @@ defined('MOODLE_INTERNAL') || die(); function question_usage_statistics_cron() { global $DB; - $expiretime = time() - 5*HOURSECS; + $expiretime = time() - 5 * HOURSECS; mtrace("\n Cleaning up old question statistics cache records...", ''); $DB->delete_records_select('question_statistics', 'timemodified < ?', array($expiretime)); - $DB->delete_records_select('question_response_analysis', 'timemodified < ?', array($expiretime)); + $responseanlysisids = $DB->get_records_select_menu('question_response_analysis', + 'timemodified < ?', + array($expiretime), + 'id', + 'id, id AS id2'); + + $DB->delete_records_list('question_response_analysis', 'id', $responseanlysisids); + $DB->delete_records_list('question_response_count', 'analysisid', $responseanlysisids); mtrace('done.'); return true; diff --git a/version.php b/version.php index d6e622e6a0a..bd6631e7345 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2014031400.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2014031400.03; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes.