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 9dd5763ed11..c2f2e6ebaff 100644
--- a/version.php
+++ b/version.php
@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
-$version = 2014031400.01; // YYYYMMDD = weekly release date of this DEV branch.
+$version = 2014031400.04; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.