MDL-20296 Units handling for numerical and calculatedquestions
[moodle.git] / question / type / numerical / questiontype.php
CommitLineData
4d41f4ee 1<?php // $Id$
1fe641f7 2/**
3 * @version $Id$
4 * @author Martin Dougiamas and many others. Tim Hunt.
5 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
1976496e 6 * @package questionbank
7 * @subpackage questiontypes
1fe641f7 8 *//** */
516cf3eb 9
aaae75b0 10require_once("$CFG->dirroot/question/type/shortanswer/questiontype.php");
516cf3eb 11
1fe641f7 12/**
13 * NUMERICAL QUESTION TYPE CLASS
14 *
15 * This class contains some special features in order to make the
16 * question type embeddable within a multianswer (cloze) question
17 *
18 * This question type behaves like shortanswer in most cases.
19 * Therefore, it extends the shortanswer question type...
1976496e 20 * @package questionbank
21 * @subpackage questiontypes
1fe641f7 22 */
32a189d6 23class question_numerical_qtype extends question_shortanswer_qtype {
516cf3eb 24
25 function name() {
26 return 'numerical';
27 }
869309b8 28
29 function has_wildcards_in_responses() {
30 return true;
31 }
32
33 function requires_qtypes() {
34 return array('shortanswer');
35 }
516cf3eb 36
37 function get_question_options(&$question) {
38 // Get the question answers and their respective tolerances
32a189d6 39 // Note: question_numerical is an extension of the answer table rather than
4f48fb42 40 // the question table as is usually the case for qtype
516cf3eb 41 // specific tables.
fef8f84e 42 global $CFG, $DB, $OUTPUT;
f34488b2 43 if (!$question->options->answers = $DB->get_records_sql(
516cf3eb 44 "SELECT a.*, n.tolerance " .
f34488b2 45 "FROM {question_answers} a, " .
46 " {question_numerical} n " .
47 "WHERE a.question = ? " .
026bec73 48 " AND a.id = n.answer " .
f34488b2 49 "ORDER BY a.id ASC", array($question->id))) {
fef8f84e 50 echo $OUTPUT->notification('Error: Missing question answer for numerical question ' . $question->id . '!');
516cf3eb 51 return false;
52 }
53 $this->get_numerical_units($question);
54
5a14d563 55 // If units are defined we strip off the default unit from the answer, if
516cf3eb 56 // it is present. (Required for compatibility with the old code and DB).
57 if ($defaultunit = $this->get_default_numerical_unit($question)) {
58 foreach($question->options->answers as $key => $val) {
59 $answer = trim($val->answer);
60 $length = strlen($defaultunit->unit);
1fe641f7 61 if ($length && substr($answer, -$length) == $defaultunit->unit) {
516cf3eb 62 $question->options->answers[$key]->answer =
1fe641f7 63 substr($answer, 0, strlen($answer)-$length);
516cf3eb 64 }
65 }
66 }
67 return true;
68 }
69
70 function get_numerical_units(&$question) {
f34488b2 71 global $DB;
72 if ($units = $DB->get_records('question_numerical_units', array('question' => $question->id), 'id ASC')) {
3a513ba4 73 $units = array_values($units);
516cf3eb 74 } else {
b4d7d27c 75 $units = array();
516cf3eb 76 }
b4d7d27c 77 foreach ($units as $key => $unit) {
78 $units[$key]->multiplier = clean_param($unit->multiplier, PARAM_NUMBER);
79 }
80 $question->options->units = $units;
516cf3eb 81 return true;
82 }
83
84 function get_default_numerical_unit(&$question) {
ed0ba6da 85 if (isset($question->options->units[0])) {
86 foreach ($question->options->units as $unit) {
87 if (abs($unit->multiplier - 1.0) < '1.0e-' . ini_get('precision')) {
88 return $unit;
516cf3eb 89 }
90 }
91 }
ed0ba6da 92 return false;
516cf3eb 93 }
94
1fe641f7 95 /**
96 * Save the units and the answers associated with this question.
97 */
516cf3eb 98 function save_question_options($question) {
f34488b2 99 global $DB;
516cf3eb 100 // Get old versions of the objects
f34488b2 101 if (!$oldanswers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC')) {
516cf3eb 102 $oldanswers = array();
103 }
104
f34488b2 105 if (!$oldoptions = $DB->get_records('question_numerical', array('question' => $question->id), 'answer ASC')) {
516cf3eb 106 $oldoptions = array();
107 }
108
1fe641f7 109 // Save the units.
516cf3eb 110 $result = $this->save_numerical_units($question);
111 if (isset($result->error)) {
112 return $result;
113 } else {
114 $units = &$result->units;
115 }
116
117 // Insert all the new answers
118 foreach ($question->answer as $key => $dataanswer) {
94a6d656 119 // Check for, and ingore, completely blank answer from the form.
120 if (trim($dataanswer) == '' && $question->fraction[$key] == 0 &&
121 html_is_blank($question->feedback[$key])) {
122 continue;
123 }
516cf3eb 124
94a6d656 125 $answer = new stdClass;
126 $answer->question = $question->id;
fac1189d 127 if (trim($dataanswer) === '*') {
94a6d656 128 $answer->answer = '*';
129 } else {
130 $answer->answer = $this->apply_unit($dataanswer, $units);
131 if ($answer->answer === false) {
132 $result->notice = get_string('invalidnumericanswer', 'quiz');
516cf3eb 133 }
94a6d656 134 }
135 $answer->fraction = $question->fraction[$key];
136 $answer->feedback = trim($question->feedback[$key]);
516cf3eb 137
94a6d656 138 if ($oldanswer = array_shift($oldanswers)) { // Existing answer, so reuse it
139 $answer->id = $oldanswer->id;
0bcf8b6f 140 $DB->update_record("question_answers", $answer);
94a6d656 141 } else { // This is a completely new answer
0bcf8b6f 142 $answer->id = $DB->insert_record("question_answers", $answer);
94a6d656 143 }
f34488b2 144
94a6d656 145 // Set up the options object
146 if (!$options = array_shift($oldoptions)) {
147 $options = new stdClass;
148 }
149 $options->question = $question->id;
150 $options->answer = $answer->id;
151 if (trim($question->tolerance[$key]) == '') {
152 $options->tolerance = '';
153 } else {
154 $options->tolerance = $this->apply_unit($question->tolerance[$key], $units);
155 if ($options->tolerance === false) {
156 $result->notice = get_string('invalidnumerictolerance', 'quiz');
157 }
158 }
159
160 // Save options
161 if (isset($options->id)) { // reusing existing record
0bcf8b6f 162 $DB->update_record('question_numerical', $options);
94a6d656 163 } else { // new options
0bcf8b6f 164 $DB->insert_record('question_numerical', $options);
1fe641f7 165 }
166 }
167 // delete old answer records
168 if (!empty($oldanswers)) {
169 foreach($oldanswers as $oa) {
f34488b2 170 $DB->delete_records('question_answers', array('id' => $oa->id));
1fe641f7 171 }
172 }
516cf3eb 173
1fe641f7 174 // delete old answer records
175 if (!empty($oldoptions)) {
176 foreach($oldoptions as $oo) {
f34488b2 177 $DB->delete_records('question_numerical', array('id' => $oo->id));
516cf3eb 178 }
179 }
1fe641f7 180
181 // Report any problems.
182 if (!empty($result->notice)) {
183 return $result;
184 }
1fe641f7 185 return true;
516cf3eb 186 }
187
188 function save_numerical_units($question) {
f34488b2 189 global $DB;
ed0ba6da 190 $result = new stdClass;
516cf3eb 191
ed0ba6da 192 // Delete the units previously saved for this question.
f34488b2 193 $DB->delete_records('question_numerical_units', array('question' => $question->id));
ed0ba6da 194
26b26662 195 // Nothing to do.
196 if (!isset($question->multiplier)) {
197 $result->units = array();
198 return $result;
199 }
200
ed0ba6da 201 // Save the new units.
516cf3eb 202 $units = array();
ed0ba6da 203 foreach ($question->multiplier as $i => $multiplier) {
516cf3eb 204 // Discard any unit which doesn't specify the unit or the multiplier
205 if (!empty($question->multiplier[$i]) && !empty($question->unit[$i])) {
ed0ba6da 206 $units[$i] = new stdClass;
516cf3eb 207 $units[$i]->question = $question->id;
1fe641f7 208 $units[$i]->multiplier = $this->apply_unit($question->multiplier[$i], array());
516cf3eb 209 $units[$i]->unit = $question->unit[$i];
979425b5 210 $DB->insert_record('question_numerical_units', $units[$i]);
516cf3eb 211 }
212 }
213 unset($question->multiplier, $question->unit);
214
516cf3eb 215 $result->units = &$units;
216 return $result;
217 }
218
219 /**
1fe641f7 220 * Deletes question from the question-type specific tables
221 *
222 * @return boolean Success/Failure
223 * @param object $question The question being deleted
224 */
90c3f310 225 function delete_question($questionid) {
f34488b2 226 global $DB;
227 $DB->delete_records("question_numerical", array("question" => $questionid));
228 $DB->delete_records("question_numerical_units", array("question" => $questionid));
516cf3eb 229 return true;
230 }
231
5a14d563 232 function compare_responses(&$question, $state, $teststate) {
233 if (isset($state->responses['']) && isset($teststate->responses[''])) {
234 return $state->responses[''] == $teststate->responses[''];
235 }
236 return false;
516cf3eb 237 }
238
1fe641f7 239 /**
240 * Checks whether a response matches a given answer, taking the tolerance
241 * and units into account. Returns a true for if a response matches the
242 * answer, false if it doesn't.
243 */
516cf3eb 244 function test_response(&$question, &$state, $answer) {
55894a42 245 // Deal with the match anything answer.
fac1189d 246 if ($answer->answer === '*') {
55894a42 247 return true;
516cf3eb 248 }
249
294ce987 250 $response = $this->apply_unit($state->responses[''], $question->options->units);
1fe641f7 251
252 if ($response === false) {
253 return false; // The student did not type a number.
516cf3eb 254 }
1fe641f7 255
256 // The student did type a number, so check it with tolerances.
257 $this->get_tolerance_interval($answer);
258 return ($answer->min <= $response && $response <= $answer->max);
516cf3eb 259 }
260
516cf3eb 261 function get_correct_responses(&$question, &$state) {
262 $correct = parent::get_correct_responses($question, $state);
c85aea03 263 $unit = $this->get_default_numerical_unit($question);
264 if (isset($correct['']) && $correct[''] != '*' && $unit) {
516cf3eb 265 $correct[''] .= ' '.$unit->unit;
266 }
267 return $correct;
268 }
269
270 // ULPGC ecastro
271 function get_all_responses(&$question, &$state) {
1fe641f7 272 $result = new stdClass;
273 $answers = array();
516cf3eb 274 $unit = $this->get_default_numerical_unit($question);
275 if (is_array($question->options->answers)) {
276 foreach ($question->options->answers as $aid=>$answer) {
1fe641f7 277 $r = new stdClass;
516cf3eb 278 $r->answer = $answer->answer;
279 $r->credit = $answer->fraction;
280 $this->get_tolerance_interval($answer);
55894a42 281 if ($r->answer != '*' && $unit) {
282 $r->answer .= ' ' . $unit->unit;
516cf3eb 283 }
284 if ($answer->max != $answer->min) {
285 $max = "$answer->max"; //format_float($answer->max, 2);
286 $min = "$answer->min"; //format_float($answer->max, 2);
287 $r->answer .= ' ('.$min.'..'.$max.')';
288 }
289 $answers[$aid] = $r;
290 }
516cf3eb 291 }
292 $result->id = $question->id;
293 $result->responses = $answers;
294 return $result;
295 }
296
297 function get_tolerance_interval(&$answer) {
298 // No tolerance
299 if (empty($answer->tolerance)) {
1fe641f7 300 $answer->tolerance = 0;
516cf3eb 301 }
302
303 // Calculate the interval of correct responses (min/max)
304 if (!isset($answer->tolerancetype)) {
305 $answer->tolerancetype = 2; // nominal
306 }
307
223ad0b9 308 // We need to add a tiny fraction depending on the set precision to make the
516cf3eb 309 // comparison work correctly. Otherwise seemingly equal values can yield
310 // false. (fixes bug #3225)
223ad0b9 311 $tolerance = (float)$answer->tolerance + ("1.0e-".ini_get('precision'));
516cf3eb 312 switch ($answer->tolerancetype) {
313 case '1': case 'relative':
314 /// Recalculate the tolerance and fall through
315 /// to the nominal case:
316 $tolerance = $answer->answer * $tolerance;
dcd4192a 317 // Do not fall through to the nominal case because the tiny fraction is a factor of the answer
318 $tolerance = abs($tolerance); // important - otherwise min and max are swapped
319 $max = $answer->answer + $tolerance;
320 $min = $answer->answer - $tolerance;
321 break;
516cf3eb 322 case '2': case 'nominal':
323 $tolerance = abs($tolerance); // important - otherwise min and max are swapped
dcd4192a 324 // $answer->tolerance 0 or something else
325 if ((float)$answer->tolerance == 0.0 && abs((float)$answer->answer) <= $tolerance ){
f34488b2 326 $tolerance = (float) ("1.0e-".ini_get('precision')) * abs((float)$answer->answer) ; //tiny fraction
dcd4192a 327 } else if ((float)$answer->tolerance != 0.0 && abs((float)$answer->tolerance) < abs((float)$answer->answer) && abs((float)$answer->answer) <= $tolerance){
f34488b2 328 $tolerance = (1+("1.0e-".ini_get('precision')) )* abs((float) $answer->tolerance) ;//tiny fraction
329 }
330
516cf3eb 331 $max = $answer->answer + $tolerance;
332 $min = $answer->answer - $tolerance;
333 break;
ed0ba6da 334 case '3': case 'geometric':
516cf3eb 335 $quotient = 1 + abs($tolerance);
336 $max = $answer->answer * $quotient;
337 $min = $answer->answer / $quotient;
338 break;
339 default:
0b4f4187 340 print_error('unknowntolerance', 'question', '', $answer->tolerancetype);
516cf3eb 341 }
342
343 $answer->min = $min;
344 $answer->max = $max;
345 return true;
346 }
347
348 /**
1fe641f7 349 * Checks if the $rawresponse has a unit and applys it if appropriate.
350 *
351 * @param string $rawresponse The response string to be converted to a float.
352 * @param array $units An array with the defined units, where the
353 * unit is the key and the multiplier the value.
354 * @return float The rawresponse with the unit taken into
355 * account as a float.
356 */
516cf3eb 357 function apply_unit($rawresponse, $units) {
358 // Make units more useful
359 $tmpunits = array();
360 foreach ($units as $unit) {
361 $tmpunits[$unit->unit] = $unit->multiplier;
362 }
1fe641f7 363 // remove spaces and normalise decimal places.
516cf3eb 364 $search = array(' ', ',');
365 $replace = array('', '.');
1fe641f7 366 $rawresponse = str_replace($search, $replace, trim($rawresponse));
f34488b2 367
1fe641f7 368 // Apply any unit that is present.
6dbcacee 369 if (preg_match('~^([+-]?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][-+]?[0-9]+)?)([^0-9].*)?$~',
1fe641f7 370 $rawresponse, $responseparts)) {
f34488b2 371
1fe641f7 372 if (!empty($responseparts[5])) {
f34488b2 373
1fe641f7 374 if (isset($tmpunits[$responseparts[5]])) {
375 // Valid number with unit.
376 return (float)$responseparts[1] / $tmpunits[$responseparts[5]];
377 } else {
378 // Valid number with invalid unit. Must be wrong.
379 return false;
380 }
381
516cf3eb 382 } else {
1fe641f7 383 // Valid number without unit.
516cf3eb 384 return (float)$responseparts[1];
385 }
386 }
1fe641f7 387 // Invalid number. Must be wrong.
388 return false;
516cf3eb 389 }
f34488b2 390
1fe641f7 391 /// BACKUP FUNCTIONS ////////////////////////////
c5d94c41 392
1fe641f7 393 /**
c5d94c41 394 * Backup the data in the question
395 *
396 * This is used in question/backuplib.php
397 */
398 function backup($bf,$preferences,$question,$level=6) {
f34488b2 399 global $DB;
c5d94c41 400
401 $status = true;
402
f34488b2 403 $numericals = $DB->get_records('question_numerical', array('question' => $question), 'id ASC');
c5d94c41 404 //If there are numericals
405 if ($numericals) {
406 //Iterate over each numerical
407 foreach ($numericals as $numerical) {
408 $status = fwrite ($bf,start_tag("NUMERICAL",$level,true));
409 //Print numerical contents
410 fwrite ($bf,full_tag("ANSWER",$level+1,false,$numerical->answer));
411 fwrite ($bf,full_tag("TOLERANCE",$level+1,false,$numerical->tolerance));
412 //Now backup numerical_units
413 $status = question_backup_numerical_units($bf,$preferences,$question,7);
414 $status = fwrite ($bf,end_tag("NUMERICAL",$level,true));
415 }
416 //Now print question_answers
417 $status = question_backup_answers($bf,$preferences,$question);
418 }
419 return $status;
420 }
421
1fe641f7 422 /// RESTORE FUNCTIONS /////////////////
315559d3 423
1fe641f7 424 /**
315559d3 425 * Restores the data in the question
426 *
427 * This is used in question/restorelib.php
428 */
429 function restore($old_question_id,$new_question_id,$info,$restore) {
9db7dab2 430 global $DB;
315559d3 431
432 $status = true;
433
434 //Get the numerical array
27cabbe6 435 if (isset($info['#']['NUMERICAL'])) {
436 $numericals = $info['#']['NUMERICAL'];
437 } else {
438 $numericals = array();
439 }
315559d3 440
441 //Iterate over numericals
442 for($i = 0; $i < sizeof($numericals); $i++) {
443 $num_info = $numericals[$i];
444
445 //Now, build the question_numerical record structure
1fe641f7 446 $numerical = new stdClass;
315559d3 447 $numerical->question = $new_question_id;
448 $numerical->answer = backup_todb($num_info['#']['ANSWER']['0']['#']);
449 $numerical->tolerance = backup_todb($num_info['#']['TOLERANCE']['0']['#']);
450
55894a42 451 //We have to recode the answer field
315559d3 452 $answer = backup_getid($restore->backup_unique_code,"question_answers",$numerical->answer);
453 if ($answer) {
454 $numerical->answer = $answer->new_id;
455 }
456
457 //The structure is equal to the db, so insert the question_numerical
9db7dab2 458 $newid = $DB->insert_record ("question_numerical", $numerical);
315559d3 459
460 //Do some output
461 if (($i+1) % 50 == 0) {
462 if (!defined('RESTORE_SILENTLY')) {
463 echo ".";
464 if (($i+1) % 1000 == 0) {
465 echo "<br />";
466 }
467 }
468 backup_flush(300);
469 }
470
471 //Now restore numerical_units
472 $status = question_restore_numerical_units ($old_question_id,$new_question_id,$num_info,$restore);
473
474 if (!$newid) {
475 $status = false;
476 }
477 }
478
479 return $status;
480 }
481
b9bd6da4 482 /**
483 * Runs all the code required to set up and save an essay question for testing purposes.
484 * Alternate DB table prefix may be used to facilitate data deletion.
485 */
486 function generate_test($name, $courseid = null) {
487 global $DB;
488 list($form, $question) = default_questiontype::generate_test($name, $courseid);
489 $question->category = $form->category;
490
491 $form->questiontext = "What is 674 * 36?";
492 $form->generalfeedback = "Thank you";
493 $form->penalty = 0.1;
494 $form->defaultgrade = 1;
495 $form->noanswers = 3;
496 $form->answer = array('24264', '24264', '1');
497 $form->tolerance = array(10, 100, 0);
498 $form->fraction = array(1, 0.5, 0);
499 $form->nounits = 2;
500 $form->unit = array(0 => null, 1 => null);
501 $form->multiplier = array(1, 0);
502 $form->feedback = array('Very good', 'Close, but not quite there', 'Well at least you tried....');
503
504 if ($courseid) {
505 $course = $DB->get_record('course', array('id' => $courseid));
506 }
507
508 return $this->save_question($question, $form, $course);
509 }
516cf3eb 510}
516cf3eb 511
1fe641f7 512// INITIATION - Without this line the question type is not in use.
a2156789 513question_register_questiontype(new question_numerical_qtype());
516cf3eb 514?>