Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 39 and 310] [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   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.
   8  //
   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.
  13  //
  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/>.
  16  
  17  /**
  18   * Numerical question definition class.
  19   *
  20   * @package    qtype
  21   * @subpackage numerical
  22   * @copyright  2009 The Open University
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  require_once($CFG->dirroot . '/question/type/questionbase.php');
  30  
  31  /**
  32   * Represents a numerical question.
  33   *
  34   * @copyright  2009 The Open University
  35   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  36   */
  37  class qtype_numerical_question extends question_graded_automatically {
  38      /** @var array of question_answer. */
  39      public $answers = array();
  40  
  41      /** @var int one of the constants UNITNONE, UNITRADIO, UNITSELECT or UNITINPUT. */
  42      public $unitdisplay;
  43      /** @var int one of the constants UNITGRADEDOUTOFMARK or UNITGRADEDOUTOFMAX. */
  44      public $unitgradingtype;
  45      /** @var number the penalty for a missing or unrecognised unit. */
  46      public $unitpenalty;
  47  
  48      /** @var qtype_numerical_answer_processor */
  49      public $ap;
  50  
  51      public function get_expected_data() {
  52          $expected = array('answer' => PARAM_RAW_TRIMMED);
  53          if ($this->has_separate_unit_field()) {
  54              $expected['unit'] = PARAM_RAW_TRIMMED;
  55          }
  56          return $expected;
  57      }
  58  
  59      public function has_separate_unit_field() {
  60          return $this->unitdisplay == qtype_numerical::UNITRADIO ||
  61                  $this->unitdisplay == qtype_numerical::UNITSELECT;
  62      }
  63  
  64      public function start_attempt(question_attempt_step $step, $variant) {
  65          $step->set_qt_var('_separators',
  66                  $this->ap->get_point() . '$' . $this->ap->get_separator());
  67      }
  68  
  69      public function apply_attempt_state(question_attempt_step $step) {
  70          list($point, $separator) = explode('$', $step->get_qt_var('_separators'));
  71                  $this->ap->set_characters($point, $separator);
  72      }
  73  
  74      public function summarise_response(array $response) {
  75          if (isset($response['answer'])) {
  76              $resp = $response['answer'];
  77          } else {
  78              $resp = null;
  79          }
  80  
  81          if ($this->has_separate_unit_field() && !empty($response['unit'])) {
  82              $resp = $this->ap->add_unit($resp, $response['unit']);
  83          }
  84  
  85          return $resp;
  86      }
  87  
  88      public function un_summarise_response(string $summary) {
  89          if ($this->has_separate_unit_field()) {
  90              throw new coding_exception('Sorry, but at the moment un_summarise_response cannot handle the
  91                  has_separate_unit_field case for numerical questions.
  92                      If you need this, you will have to implement it yourself.');
  93          }
  94  
  95          if (!empty($summary)) {
  96              return ['answer' => $summary];
  97          } else {
  98              return [];
  99          }
 100      }
 101  
 102      public function is_gradable_response(array $response) {
 103          return array_key_exists('answer', $response) &&
 104                  ($response['answer'] || $response['answer'] === '0' || $response['answer'] === 0);
 105      }
 106  
 107      public function is_complete_response(array $response) {
 108          if (!$this->is_gradable_response($response)) {
 109              return false;
 110          }
 111  
 112          list($value, $unit) = $this->ap->apply_units($response['answer']);
 113          if (is_null($value)) {
 114              return false;
 115          }
 116  
 117          if ($this->unitdisplay != qtype_numerical::UNITINPUT && $unit) {
 118              return false;
 119          }
 120  
 121          if ($this->has_separate_unit_field() && empty($response['unit'])) {
 122              return false;
 123          }
 124  
 125          if ($this->ap->contains_thousands_seaparator($response['answer'])) {
 126              return false;
 127          }
 128  
 129          return true;
 130      }
 131  
 132      public function get_validation_error(array $response) {
 133          if (!$this->is_gradable_response($response)) {
 134              return get_string('pleaseenterananswer', 'qtype_numerical');
 135          }
 136  
 137          list($value, $unit) = $this->ap->apply_units($response['answer']);
 138          if (is_null($value)) {
 139              return get_string('invalidnumber', 'qtype_numerical');
 140          }
 141  
 142          if ($this->unitdisplay != qtype_numerical::UNITINPUT && $unit) {
 143              return get_string('invalidnumbernounit', 'qtype_numerical');
 144          }
 145  
 146          if ($this->has_separate_unit_field() && empty($response['unit'])) {
 147              return get_string('unitnotselected', 'qtype_numerical');
 148          }
 149  
 150          if ($this->ap->contains_thousands_seaparator($response['answer'])) {
 151              return get_string('pleaseenteranswerwithoutthousandssep', 'qtype_numerical',
 152                      $this->ap->get_separator());
 153          }
 154  
 155          return '';
 156      }
 157  
 158      public function is_same_response(array $prevresponse, array $newresponse) {
 159          if (!question_utils::arrays_same_at_key_missing_is_blank(
 160                  $prevresponse, $newresponse, 'answer')) {
 161              return false;
 162          }
 163  
 164          if ($this->has_separate_unit_field()) {
 165              return question_utils::arrays_same_at_key_missing_is_blank(
 166                  $prevresponse, $newresponse, 'unit');
 167          }
 168  
 169          return true;
 170      }
 171  
 172      public function get_correct_response() {
 173          $answer = $this->get_correct_answer();
 174          if (!$answer) {
 175              return array();
 176          }
 177  
 178          $response = array('answer' => str_replace('.', $this->ap->get_point(), $answer->answer));
 179  
 180          if ($this->has_separate_unit_field()) {
 181              $response['unit'] = $this->ap->get_default_unit();
 182          } else if ($this->unitdisplay == qtype_numerical::UNITINPUT) {
 183              $response['answer'] = $this->ap->add_unit($answer->answer);
 184          }
 185  
 186          return $response;
 187      }
 188  
 189      /**
 190       * Get an answer that contains the feedback and fraction that should be
 191       * awarded for this response.
 192       * @param number $value the numerical value of a response.
 193       * @param number $multiplier for the unit the student gave, if any. When no
 194       *      unit was given, or an unrecognised unit was given, $multiplier will be null.
 195       * @return question_answer the matching answer.
 196       */
 197      public function get_matching_answer($value, $multiplier) {
 198          if (is_null($value) || $value === '') {
 199              return null;
 200          }
 201  
 202          if (!is_null($multiplier)) {
 203              $scaledvalue = $value * $multiplier;
 204          } else {
 205              $scaledvalue = $value;
 206          }
 207          foreach ($this->answers as $answer) {
 208              if ($answer->within_tolerance($scaledvalue)) {
 209                  $answer->unitisright = !is_null($multiplier);
 210                  return $answer;
 211              } else if ($answer->within_tolerance($value)) {
 212                  $answer->unitisright = false;
 213                  return $answer;
 214              }
 215          }
 216  
 217          return null;
 218      }
 219  
 220      public function get_correct_answer() {
 221          foreach ($this->answers as $answer) {
 222              $state = question_state::graded_state_for_fraction($answer->fraction);
 223              if ($state == question_state::$gradedright) {
 224                  return $answer;
 225              }
 226          }
 227          return null;
 228      }
 229  
 230      /**
 231       * Adjust the fraction based on whether the unit was correct.
 232       * @param number $fraction
 233       * @param bool $unitisright
 234       * @return number
 235       */
 236      public function apply_unit_penalty($fraction, $unitisright) {
 237          if ($unitisright) {
 238              return $fraction;
 239          }
 240  
 241          if ($this->unitgradingtype == qtype_numerical::UNITGRADEDOUTOFMARK) {
 242              $fraction -= $this->unitpenalty * $fraction;
 243          } else if ($this->unitgradingtype == qtype_numerical::UNITGRADEDOUTOFMAX) {
 244              $fraction -= $this->unitpenalty;
 245          }
 246          return max($fraction, 0);
 247      }
 248  
 249      public function grade_response(array $response) {
 250          if ($this->has_separate_unit_field()) {
 251              $selectedunit = $response['unit'];
 252          } else {
 253              $selectedunit = null;
 254          }
 255          list($value, $unit, $multiplier) = $this->ap->apply_units(
 256                  $response['answer'], $selectedunit);
 257  
 258          $answer = $this->get_matching_answer($value, $multiplier);
 259          if (!$answer) {
 260              return array(0, question_state::$gradedwrong);
 261          }
 262  
 263          $fraction = $this->apply_unit_penalty($answer->fraction, $answer->unitisright);
 264          return array($fraction, question_state::graded_state_for_fraction($fraction));
 265      }
 266  
 267      public function classify_response(array $response) {
 268          if (!$this->is_gradable_response($response)) {
 269              return array($this->id => question_classified_response::no_response());
 270          }
 271  
 272          if ($this->has_separate_unit_field()) {
 273              $selectedunit = $response['unit'];
 274          } else {
 275              $selectedunit = null;
 276          }
 277          list($value, $unit, $multiplier) = $this->ap->apply_units($response['answer'], $selectedunit);
 278          $ans = $this->get_matching_answer($value, $multiplier);
 279  
 280          $resp = $response['answer'];
 281          if ($this->has_separate_unit_field()) {
 282              $resp = $this->ap->add_unit($resp, $unit);
 283          }
 284  
 285          if ($value === null) {
 286              // Invalid response shown as no response (but show actual response).
 287              return array($this->id => new question_classified_response(null, $resp, 0));
 288          } else if (!$ans) {
 289              // Does not match any answer.
 290              return array($this->id => new question_classified_response(0, $resp, 0));
 291          }
 292  
 293          return array($this->id => new question_classified_response($ans->id,
 294                  $resp,
 295                  $this->apply_unit_penalty($ans->fraction, $ans->unitisright)));
 296      }
 297  
 298      public function check_file_access($qa, $options, $component, $filearea, $args,
 299              $forcedownload) {
 300          if ($component == 'question' && $filearea == 'answerfeedback') {
 301              $currentanswer = $qa->get_last_qt_var('answer');
 302              if ($this->has_separate_unit_field()) {
 303                  $selectedunit = $qa->get_last_qt_var('unit');
 304              } else {
 305                  $selectedunit = null;
 306              }
 307              list($value, $unit, $multiplier) = $this->ap->apply_units(
 308                      $currentanswer, $selectedunit);
 309              $answer = $this->get_matching_answer($value, $multiplier);
 310              $answerid = reset($args); // Itemid is answer id.
 311              return $options->feedback && $answer && $answerid == $answer->id;
 312  
 313          } else if ($component == 'question' && $filearea == 'hint') {
 314              return $this->check_hint_file_access($qa, $options, $args);
 315  
 316          } else {
 317              return parent::check_file_access($qa, $options, $component, $filearea,
 318                      $args, $forcedownload);
 319          }
 320      }
 321  }
 322  
 323  
 324  /**
 325   * Subclass of {@link question_answer} with the extra information required by
 326   * the numerical question type.
 327   *
 328   * @copyright  2009 The Open University
 329   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 330   */
 331  class qtype_numerical_answer extends question_answer {
 332      /** @var float allowable margin of error. */
 333      public $tolerance;
 334      /** @var integer|string see {@link get_tolerance_interval()} for the meaning of this value. */
 335      public $tolerancetype = 2;
 336  
 337      public function __construct($id, $answer, $fraction, $feedback, $feedbackformat, $tolerance) {
 338          parent::__construct($id, $answer, $fraction, $feedback, $feedbackformat);
 339          $this->tolerance = abs($tolerance);
 340      }
 341  
 342      public function get_tolerance_interval() {
 343          if ($this->answer === '*') {
 344              throw new coding_exception('Cannot work out tolerance interval for answer *.');
 345          }
 346  
 347          // Smallest number that, when added to 1, is different from 1.
 348          $epsilon = pow(10, -1 * ini_get('precision'));
 349  
 350          // We need to add a tiny fraction depending on the set precision to make
 351          // the comparison work correctly, otherwise seemingly equal values can
 352          // yield false. See MDL-3225.
 353          $tolerance = abs($this->tolerance) + $epsilon;
 354  
 355          switch ($this->tolerancetype) {
 356              case 1: case 'relative':
 357                  $range = abs($this->answer) * $tolerance;
 358                  return array($this->answer - $range, $this->answer + $range);
 359  
 360              case 2: case 'nominal':
 361                  $tolerance = $this->tolerance + $epsilon * max(abs($this->tolerance), abs($this->answer), $epsilon);
 362                  return array($this->answer - $tolerance, $this->answer + $tolerance);
 363  
 364              case 3: case 'geometric':
 365                  $quotient = 1 + abs($tolerance);
 366                  return array($this->answer / $quotient, $this->answer * $quotient);
 367  
 368              default:
 369                  throw new coding_exception('Unknown tolerance type ' . $this->tolerancetype);
 370          }
 371      }
 372  
 373      public function within_tolerance($value) {
 374          if ($this->answer === '*') {
 375              return true;
 376          }
 377          list($min, $max) = $this->get_tolerance_interval();
 378          return $min <= $value && $value <= $max;
 379      }
 380  }