2 // This file is part of Moodle - http://moodle.org/
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
19 * Question type class for the numerical question type.
22 * @subpackage numerical
23 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28 defined('MOODLE_INTERNAL') || die();
30 require_once($CFG->libdir . '/questionlib.php');
31 require_once($CFG->dirroot . '/question/type/numerical/question.php');
35 * The numerical question type class.
37 * This class contains some special features in order to make the
38 * question type embeddable within a multianswer (cloze) question
40 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
41 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43 class qtype_numerical extends question_type {
50 const UNITOPTIONAL = 0;
52 const UNITGRADEDOUTOFMARK = 1;
53 const UNITGRADEDOUTOFMAX = 2;
56 * Validate that a string is a number formatted correctly for the current locale.
57 * @param string $x a string
58 * @return bool whether $x is a number that the numerical question type can interpret.
60 public static function is_valid_number(string $x) : bool {
61 $ap = new qtype_numerical_answer_processor(array());
62 list($value, $unit) = $ap->apply_units($x);
63 return !is_null($value) && !$unit;
66 public function get_question_options($question) {
67 global $CFG, $DB, $OUTPUT;
68 parent::get_question_options($question);
69 // Get the question answers and their respective tolerances
70 // Note: question_numerical is an extension of the answer table rather than
71 // the question table as is usually the case for qtype
73 if (!$question->options->answers = $DB->get_records_sql(
74 "SELECT a.*, n.tolerance " .
75 "FROM {question_answers} a, " .
76 " {question_numerical} n " .
77 "WHERE a.question = ? " .
78 " AND a.id = n.answer " .
79 "ORDER BY a.id ASC", array($question->id))) {
80 echo $OUTPUT->notification('Error: Missing question answer for numerical question ' .
85 $question->hints = $DB->get_records('question_hints',
86 array('questionid' => $question->id), 'id ASC');
88 $this->get_numerical_units($question);
89 // Get_numerical_options() need to know if there are units
90 // to set correctly default values.
91 $this->get_numerical_options($question);
93 // If units are defined we strip off the default unit from the answer, if
94 // it is present. (Required for compatibility with the old code and DB).
95 if ($defaultunit = $this->get_default_numerical_unit($question)) {
96 foreach ($question->options->answers as $key => $val) {
97 $answer = trim($val->answer);
98 $length = strlen($defaultunit->unit);
99 if ($length && substr($answer, -$length) == $defaultunit->unit) {
100 $question->options->answers[$key]->answer =
101 substr($answer, 0, strlen($answer)-$length);
109 public function get_numerical_units(&$question) {
112 if ($units = $DB->get_records('question_numerical_units',
113 array('question' => $question->id), 'id ASC')) {
114 $units = array_values($units);
118 foreach ($units as $key => $unit) {
119 $units[$key]->multiplier = clean_param($unit->multiplier, PARAM_FLOAT);
121 $question->options->units = $units;
125 public function get_default_numerical_unit($question) {
126 if (isset($question->options->units[0])) {
127 foreach ($question->options->units as $unit) {
128 if (abs($unit->multiplier - 1.0) < '1.0e-' . ini_get('precision')) {
136 public function get_numerical_options($question) {
138 if (!$options = $DB->get_record('question_numerical_options',
139 array('question' => $question->id))) {
140 // Old question, set defaults.
141 $question->options->unitgradingtype = 0;
142 $question->options->unitpenalty = 0.1;
143 if ($defaultunit = $this->get_default_numerical_unit($question)) {
144 $question->options->showunits = self::UNITINPUT;
146 $question->options->showunits = self::UNITNONE;
148 $question->options->unitsleft = 0;
151 $question->options->unitgradingtype = $options->unitgradingtype;
152 $question->options->unitpenalty = $options->unitpenalty;
153 $question->options->showunits = $options->showunits;
154 $question->options->unitsleft = $options->unitsleft;
161 * Save the units and the answers associated with this question.
163 public function save_question_options($question) {
165 $context = $question->context;
167 // Get old versions of the objects.
168 $oldanswers = $DB->get_records('question_answers',
169 array('question' => $question->id), 'id ASC');
170 $oldoptions = $DB->get_records('question_numerical',
171 array('question' => $question->id), 'answer ASC');
174 $result = $this->save_units($question);
175 if (isset($result->error)) {
178 $units = $result->units;
181 // Insert all the new answers.
182 foreach ($question->answer as $key => $answerdata) {
183 // Check for, and ingore, completely blank answer from the form.
184 if (trim($answerdata) == '' && $question->fraction[$key] == 0 &&
185 html_is_blank($question->feedback[$key]['text'])) {
189 // Update an existing answer if possible.
190 $answer = array_shift($oldanswers);
192 $answer = new stdClass();
193 $answer->question = $question->id;
194 $answer->answer = '';
195 $answer->feedback = '';
196 $answer->id = $DB->insert_record('question_answers', $answer);
199 if (trim($answerdata) === '*') {
200 $answer->answer = '*';
202 $answer->answer = $this->apply_unit($answerdata, $units,
203 !empty($question->unitsleft));
204 if ($answer->answer === false) {
205 $result->notice = get_string('invalidnumericanswer', 'qtype_numerical');
208 $answer->fraction = $question->fraction[$key];
209 $answer->feedback = $this->import_or_save_files($question->feedback[$key],
210 $context, 'question', 'answerfeedback', $answer->id);
211 $answer->feedbackformat = $question->feedback[$key]['format'];
212 $DB->update_record('question_answers', $answer);
214 // Set up the options object.
215 if (!$options = array_shift($oldoptions)) {
216 $options = new stdClass();
218 $options->question = $question->id;
219 $options->answer = $answer->id;
220 if (trim($question->tolerance[$key]) == '') {
221 $options->tolerance = '';
223 $options->tolerance = $this->apply_unit($question->tolerance[$key],
224 $units, !empty($question->unitsleft));
225 if ($options->tolerance === false) {
226 $result->notice = get_string('invalidnumerictolerance', 'qtype_numerical');
228 $options->tolerance = (string)$options->tolerance;
230 if (isset($options->id)) {
231 $DB->update_record('question_numerical', $options);
233 $DB->insert_record('question_numerical', $options);
237 // Delete any left over old answer records.
238 $fs = get_file_storage();
239 foreach ($oldanswers as $oldanswer) {
240 $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id);
241 $DB->delete_records('question_answers', array('id' => $oldanswer->id));
243 foreach ($oldoptions as $oldoption) {
244 $DB->delete_records('question_numerical', array('id' => $oldoption->id));
247 $result = $this->save_unit_options($question);
248 if (!empty($result->error) || !empty($result->notice)) {
252 $this->save_hints($question);
258 * The numerical options control the display and the grading of the unit
259 * part of the numerical question and related types (calculateds)
260 * Questions previous to 2.0 do not have this table as multianswer questions
261 * in all versions including 2.0. The default values are set to give the same grade
265 public function save_unit_options($question) {
267 $result = new stdClass();
270 $options = $DB->get_record('question_numerical_options',
271 array('question' => $question->id));
273 $options = new stdClass();
274 $options->question = $question->id;
275 $options->id = $DB->insert_record('question_numerical_options', $options);
278 if (isset($question->unitpenalty)) {
279 $options->unitpenalty = $question->unitpenalty;
281 // Either an old question or a close question type.
282 $options->unitpenalty = 1;
285 $options->unitgradingtype = 0;
286 if (isset($question->unitrole)) {
287 // Saving the editing form.
288 $options->showunits = $question->unitrole;
289 if ($question->unitrole == self::UNITGRADED) {
290 $options->unitgradingtype = $question->unitgradingtypes;
291 $options->showunits = $question->multichoicedisplay;
294 } else if (isset($question->showunits)) {
295 // Updated import, e.g. Moodle XML.
296 $options->showunits = $question->showunits;
297 if (isset($question->unitgradingtype)) {
298 $options->unitgradingtype = $question->unitgradingtype;
302 if ($defaultunit = $this->get_default_numerical_unit($question)) {
303 $options->showunits = self::UNITINPUT;
305 $options->showunits = self::UNITNONE;
309 $options->unitsleft = !empty($question->unitsleft);
311 $DB->update_record('question_numerical_options', $options);
313 // Report any problems.
314 if (!empty($result->notice)) {
321 public function save_units($question) {
323 $result = new stdClass();
325 // Delete the units previously saved for this question.
326 $DB->delete_records('question_numerical_units', array('question' => $question->id));
329 if (!isset($question->multiplier)) {
330 $result->units = array();
334 // Save the new units.
336 $unitalreadyinsert = array();
337 foreach ($question->multiplier as $i => $multiplier) {
338 // Discard any unit which doesn't specify the unit or the multiplier.
339 if (!empty($question->multiplier[$i]) && !empty($question->unit[$i]) &&
340 !array_key_exists($question->unit[$i], $unitalreadyinsert)) {
341 $unitalreadyinsert[$question->unit[$i]] = 1;
342 $units[$i] = new stdClass();
343 $units[$i]->question = $question->id;
344 $units[$i]->multiplier = $this->apply_unit($question->multiplier[$i],
346 $units[$i]->unit = $question->unit[$i];
347 $DB->insert_record('question_numerical_units', $units[$i]);
350 unset($question->multiplier, $question->unit);
352 $result->units = &$units;
356 protected function initialise_question_instance(question_definition $question, $questiondata) {
357 parent::initialise_question_instance($question, $questiondata);
358 $this->initialise_numerical_answers($question, $questiondata);
359 $question->unitdisplay = $questiondata->options->showunits;
360 $question->unitgradingtype = $questiondata->options->unitgradingtype;
361 $question->unitpenalty = $questiondata->options->unitpenalty;
362 $question->unitsleft = $questiondata->options->unitsleft;
363 $question->ap = $this->make_answer_processor($questiondata->options->units,
364 $questiondata->options->unitsleft);
367 public function initialise_numerical_answers(question_definition $question, $questiondata) {
368 $question->answers = array();
369 if (empty($questiondata->options->answers)) {
372 foreach ($questiondata->options->answers as $a) {
373 $question->answers[$a->id] = new qtype_numerical_answer($a->id, $a->answer,
374 $a->fraction, $a->feedback, $a->feedbackformat, $a->tolerance);
378 public function make_answer_processor($units, $unitsleft) {
380 return new qtype_numerical_answer_processor(array());
383 $cleanedunits = array();
384 foreach ($units as $unit) {
385 $cleanedunits[$unit->unit] = $unit->multiplier;
388 return new qtype_numerical_answer_processor($cleanedunits, $unitsleft);
391 public function delete_question($questionid, $contextid) {
393 $DB->delete_records('question_numerical', array('question' => $questionid));
394 $DB->delete_records('question_numerical_options', array('question' => $questionid));
395 $DB->delete_records('question_numerical_units', array('question' => $questionid));
397 parent::delete_question($questionid, $contextid);
400 public function get_random_guess_score($questiondata) {
401 foreach ($questiondata->options->answers as $aid => $answer) {
402 if ('*' == trim($answer->answer)) {
403 return max($answer->fraction - $questiondata->options->unitpenalty, 0);
410 * Add a unit to a response for display.
411 * @param object $questiondata the data defining the quetsion.
412 * @param string $answer a response.
413 * @param object $unit a unit. If null, {@link get_default_numerical_unit()}
416 public function add_unit($questiondata, $answer, $unit = null) {
417 if (is_null($unit)) {
418 $unit = $this->get_default_numerical_unit($questiondata);
425 if (!empty($questiondata->options->unitsleft)) {
426 return $unit->unit . ' ' . $answer;
428 return $answer . ' ' . $unit->unit;
432 public function get_possible_responses($questiondata) {
433 $responses = array();
435 $unit = $this->get_default_numerical_unit($questiondata);
438 foreach ($questiondata->options->answers as $aid => $answer) {
439 $responseclass = $answer->answer;
441 if ($responseclass === '*') {
444 $responseclass = $this->add_unit($questiondata, $responseclass, $unit);
446 $ans = new qtype_numerical_answer($answer->id, $answer->answer, $answer->fraction,
447 $answer->feedback, $answer->feedbackformat, $answer->tolerance);
448 list($min, $max) = $ans->get_tolerance_interval();
449 $responseclass .= " ({$min}..{$max})";
452 $responses[$aid] = new question_possible_response($responseclass,
457 $responses[0] = new question_possible_response(
458 get_string('didnotmatchanyanswer', 'question'), 0);
461 $responses[null] = question_possible_response::no_response();
463 return array($questiondata->id => $responses);
467 * Checks if the $rawresponse has a unit and applys it if appropriate.
469 * @param string $rawresponse The response string to be converted to a float.
470 * @param array $units An array with the defined units, where the
471 * unit is the key and the multiplier the value.
472 * @return float The rawresponse with the unit taken into
473 * account as a float.
475 public function apply_unit($rawresponse, $units, $unitsleft) {
476 $ap = $this->make_answer_processor($units, $unitsleft);
477 list($value, $unit, $multiplier) = $ap->apply_units($rawresponse);
478 if (!is_null($multiplier)) {
479 $value *= $multiplier;
484 public function move_files($questionid, $oldcontextid, $newcontextid) {
485 $fs = get_file_storage();
487 parent::move_files($questionid, $oldcontextid, $newcontextid);
488 $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid);
489 $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
492 protected function delete_files($questionid, $contextid) {
493 $fs = get_file_storage();
495 parent::delete_files($questionid, $contextid);
496 $this->delete_files_in_answers($questionid, $contextid);
497 $this->delete_files_in_hints($questionid, $contextid);
503 * This class processes numbers with units.
505 * @copyright 2010 The Open University
506 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
508 class qtype_numerical_answer_processor {
509 /** @var array unit name => multiplier. */
511 /** @var string character used as decimal point. */
513 /** @var string character used as thousands separator. */
514 protected $thousandssep;
515 /** @var boolean whether the units come before or after the number. */
516 protected $unitsbefore;
518 protected $regex = null;
520 public function __construct($units, $unitsbefore = false, $decsep = null,
521 $thousandssep = null) {
522 if (is_null($decsep)) {
523 $decsep = get_string('decsep', 'langconfig');
525 $this->decsep = $decsep;
527 if (is_null($thousandssep)) {
528 $thousandssep = get_string('thousandssep', 'langconfig');
530 $this->thousandssep = $thousandssep;
532 $this->units = $units;
533 $this->unitsbefore = $unitsbefore;
537 * Set the decimal point and thousands separator character that should be used.
538 * @param string $decsep
539 * @param string $thousandssep
541 public function set_characters($decsep, $thousandssep) {
542 $this->decsep = $decsep;
543 $this->thousandssep = $thousandssep;
547 /** @return string the decimal point character used. */
548 public function get_point() {
549 return $this->decsep;
552 /** @return string the thousands separator character used. */
553 public function get_separator() {
554 return $this->thousandssep;
558 * @return bool If the student's response contains a '.' or a ',' that
559 * matches the thousands separator in the current locale. In this case, the
560 * parsing in apply_unit can give a result that the student did not expect.
562 public function contains_thousands_seaparator($value) {
563 if (!in_array($this->thousandssep, array('.', ','))) {
567 return strpos($value, $this->thousandssep) !== false;
571 * Create the regular expression that {@link parse_response()} requires.
574 protected function build_regex() {
575 if (!is_null($this->regex)) {
579 $decsep = preg_quote($this->decsep, '/');
580 $thousandssep = preg_quote($this->thousandssep, '/');
581 $beforepointre = '([+-]?[' . $thousandssep . '\d]*)';
582 $decimalsre = $decsep . '(\d*)';
583 $exponentre = '(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)';
585 $numberbit = "{$beforepointre}(?:{$decimalsre})?(?:{$exponentre})?";
587 if ($this->unitsbefore) {
588 $this->regex = "/{$numberbit}$/";
590 $this->regex = "/^{$numberbit}/";
596 * This method can be used for more locale-strict parsing of repsonses. At the
597 * moment we don't use it, and instead use the more lax parsing in apply_units.
598 * This is just a note that this funciton was used in the past, so if you are
599 * intersted, look through version control history.
601 * Take a string which is a number with or without a decimal point and exponent,
602 * and possibly followed by one of the units, and split it into bits.
603 * @param string $response a value, optionally with a unit.
604 * @return array four strings (some of which may be blank) the digits before
605 * and after the decimal point, the exponent, and the unit. All four will be
606 * null if the response cannot be parsed.
608 protected function parse_response($response) {
609 if (!preg_match($this->build_regex(), $response, $matches)) {
610 return array(null, null, null, null);
613 $matches += array('', '', '', ''); // Fill in any missing matches.
614 list($matchedpart, $beforepoint, $decimals, $exponent) = $matches;
616 // Strip out thousands separators.
617 $beforepoint = str_replace($this->thousandssep, '', $beforepoint);
619 // Must be either something before, or something after the decimal point.
620 // (The only way to do this in the regex would make it much more complicated.)
621 if ($beforepoint === '' && $decimals === '') {
622 return array(null, null, null, null);
625 if ($this->unitsbefore) {
626 $unit = substr($response, 0, -strlen($matchedpart));
628 $unit = substr($response, strlen($matchedpart));
632 return array($beforepoint, $decimals, $exponent, $unit);
636 * Takes a number in almost any localised form, and possibly with a unit
637 * after it. It separates off the unit, if present, and converts to the
638 * default unit, by using the given unit multiplier.
640 * @param string $response a value, optionally with a unit.
641 * @return array(numeric, sting) the value with the unit stripped, and normalised
642 * by the unit multiplier, if any, and the unit string, for reference.
644 public function apply_units($response, $separateunit = null) {
645 // Strip spaces (which may be thousands separators) and change other forms
646 // of writing e to e.
647 $response = str_replace(' ', '', $response);
648 $response = preg_replace('~(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)~', 'e$1', $response);
650 // If a . is present or there are multiple , (i.e. 2,456,789 ) assume ,
651 // is a thouseands separator, and strip it, else assume it is a decimal
652 // separator, and change it to ..
653 if (strpos($response, '.') !== false || substr_count($response, ',') > 1) {
654 $response = str_replace(',', '', $response);
656 $response = str_replace([$this->thousandssep, $this->decsep, ','], ['', '.', '.'], $response);
659 $regex = '[+-]?(?:\d+(?:\\.\d*)?|\\.\d+)(?:e[-+]?\d+)?';
660 if ($this->unitsbefore) {
661 $regex = "/{$regex}$/";
663 $regex = "/^{$regex}/";
665 if (!preg_match($regex, $response, $matches)) {
666 return array(null, null, null);
669 $numberstring = $matches[0];
670 if ($this->unitsbefore) {
671 // Substr returns false when it means '', so cast back to string.
672 $unit = (string) substr($response, 0, -strlen($numberstring));
674 $unit = (string) substr($response, strlen($numberstring));
677 if (!is_null($separateunit)) {
678 $unit = $separateunit;
681 if ($this->is_known_unit($unit)) {
682 $multiplier = 1 / $this->units[$unit];
687 return array($numberstring + 0, $unit, $multiplier); // The + 0 is to convert to number.
691 * @return string the default unit.
693 public function get_default_unit() {
695 return key($this->units);
699 * @param string $answer a response.
700 * @param string $unit a unit.
702 public function add_unit($answer, $unit = null) {
703 if (is_null($unit)) {
704 $unit = $this->get_default_unit();
711 if ($this->unitsbefore) {
712 return $unit . ' ' . $answer;
714 return $answer . ' ' . $unit;
719 * Is this unit recognised.
720 * @param string $unit the unit
721 * @return bool whether this is a unit we recognise.
723 public function is_known_unit($unit) {
724 return array_key_exists($unit, $this->units);
728 * Whether the units go before or after the number.
729 * @return true = before, false = after.
731 public function are_units_before() {
732 return $this->unitsbefore;
736 * Get the units as an array suitably for passing to html_writer::select.
737 * @return array of unit choices.
739 public function get_unit_options() {
741 foreach ($this->units as $unit => $notused) {
742 $options[$unit] = $unit;