Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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

   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  /**
  19   * Question type class for the numerical question type.
  20   *
  21   * @package    qtype
  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
  25   */
  26  
  27  
  28  defined('MOODLE_INTERNAL') || die();
  29  
  30  require_once($CFG->libdir . '/questionlib.php');
  31  require_once($CFG->dirroot . '/question/type/numerical/question.php');
  32  
  33  
  34  /**
  35   * The numerical question type class.
  36   *
  37   * This class contains some special features in order to make the
  38   * question type embeddable within a multianswer (cloze) question
  39   *
  40   * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
  41   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  42   */
  43  class qtype_numerical extends question_type {
  44      const UNITINPUT = 0;
  45      const UNITRADIO = 1;
  46      const UNITSELECT = 2;
  47  
  48      const UNITNONE = 3;
  49      const UNITGRADED = 1;
  50      const UNITOPTIONAL = 0;
  51  
  52      const UNITGRADEDOUTOFMARK = 1;
  53      const UNITGRADEDOUTOFMAX = 2;
  54  
  55      /**
  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.
  59       */
  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;
  64      }
  65  
  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
  72          //       specific tables.
  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 ' .
  81                      $question->id . '!');
  82              return false;
  83          }
  84  
  85          $question->hints = $DB->get_records('question_hints',
  86                  array('questionid' => $question->id), 'id ASC');
  87  
  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);
  92  
  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);
 102                  }
 103              }
 104          }
 105  
 106          return true;
 107      }
 108  
 109      public function get_numerical_units(&$question) {
 110          global $DB;
 111  
 112          if ($units = $DB->get_records('question_numerical_units',
 113                  array('question' => $question->id), 'id ASC')) {
 114              $units = array_values($units);
 115          } else {
 116              $units = array();
 117          }
 118          foreach ($units as $key => $unit) {
 119              $units[$key]->multiplier = clean_param($unit->multiplier, PARAM_FLOAT);
 120          }
 121          $question->options->units = $units;
 122          return true;
 123      }
 124  
 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')) {
 129                      return $unit;
 130                  }
 131              }
 132          }
 133          return false;
 134      }
 135  
 136      public function get_numerical_options($question) {
 137          global $DB;
 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;
 145              } else {
 146                  $question->options->showunits = self::UNITNONE;
 147              }
 148              $question->options->unitsleft = 0;
 149  
 150          } else {
 151              $question->options->unitgradingtype = $options->unitgradingtype;
 152              $question->options->unitpenalty = $options->unitpenalty;
 153              $question->options->showunits = $options->showunits;
 154              $question->options->unitsleft = $options->unitsleft;
 155          }
 156  
 157          return true;
 158      }
 159  
 160      public function save_defaults_for_new_questions(stdClass $fromform): void {
 161          parent::save_defaults_for_new_questions($fromform);
 162          $this->set_default_value('unitrole', $fromform->unitrole);
 163          $this->set_default_value('unitpenalty', $fromform->unitpenalty);
 164          $this->set_default_value('unitgradingtypes', $fromform->unitgradingtypes);
 165          $this->set_default_value('multichoicedisplay', $fromform->multichoicedisplay);
 166          $this->set_default_value('unitsleft', $fromform->unitsleft);
 167      }
 168  
 169      /**
 170       * Save the units and the answers associated with this question.
 171       */
 172      public function save_question_options($question) {
 173          global $DB;
 174          $context = $question->context;
 175  
 176          // Get old versions of the objects.
 177          $oldanswers = $DB->get_records('question_answers',
 178                  array('question' => $question->id), 'id ASC');
 179          $oldoptions = $DB->get_records('question_numerical',
 180                  array('question' => $question->id), 'answer ASC');
 181  
 182          // Save the units.
 183          $result = $this->save_units($question);
 184          if (isset($result->error)) {
 185              return $result;
 186          } else {
 187              $units = $result->units;
 188          }
 189  
 190          // Insert all the new answers.
 191          foreach ($question->answer as $key => $answerdata) {
 192              // Check for, and ingore, completely blank answer from the form.
 193              if (trim($answerdata) == '' && $question->fraction[$key] == 0 &&
 194                      html_is_blank($question->feedback[$key]['text'])) {
 195                  continue;
 196              }
 197  
 198              // Update an existing answer if possible.
 199              $answer = array_shift($oldanswers);
 200              if (!$answer) {
 201                  $answer = new stdClass();
 202                  $answer->question = $question->id;
 203                  $answer->answer = '';
 204                  $answer->feedback = '';
 205                  $answer->id = $DB->insert_record('question_answers', $answer);
 206              }
 207  
 208              if (trim($answerdata) === '*') {
 209                  $answer->answer = '*';
 210              } else {
 211                  $answer->answer = $this->apply_unit($answerdata, $units,
 212                          !empty($question->unitsleft));
 213                  if ($answer->answer === false) {
 214                      $result->notice = get_string('invalidnumericanswer', 'qtype_numerical');
 215                  }
 216              }
 217              $answer->fraction = $question->fraction[$key];
 218              $answer->feedback = $this->import_or_save_files($question->feedback[$key],
 219                      $context, 'question', 'answerfeedback', $answer->id);
 220              $answer->feedbackformat = $question->feedback[$key]['format'];
 221              $DB->update_record('question_answers', $answer);
 222  
 223              // Set up the options object.
 224              if (!$options = array_shift($oldoptions)) {
 225                  $options = new stdClass();
 226              }
 227              $options->question = $question->id;
 228              $options->answer   = $answer->id;
 229              if (trim($question->tolerance[$key]) == '') {
 230                  $options->tolerance = '';
 231              } else {
 232                  $options->tolerance = $this->apply_unit($question->tolerance[$key],
 233                          $units, !empty($question->unitsleft));
 234                  if ($options->tolerance === false) {
 235                      $result->notice = get_string('invalidnumerictolerance', 'qtype_numerical');
 236                  }
 237                  $options->tolerance = (string)$options->tolerance;
 238              }
 239              if (isset($options->id)) {
 240                  $DB->update_record('question_numerical', $options);
 241              } else {
 242                  $DB->insert_record('question_numerical', $options);
 243              }
 244          }
 245  
 246          // Delete any left over old answer records.
 247          $fs = get_file_storage();
 248          foreach ($oldanswers as $oldanswer) {
 249              $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id);
 250              $DB->delete_records('question_answers', array('id' => $oldanswer->id));
 251          }
 252          foreach ($oldoptions as $oldoption) {
 253              $DB->delete_records('question_numerical', array('id' => $oldoption->id));
 254          }
 255  
 256          $result = $this->save_unit_options($question);
 257          if (!empty($result->error) || !empty($result->notice)) {
 258              return $result;
 259          }
 260  
 261          $this->save_hints($question);
 262  
 263          return true;
 264      }
 265  
 266      /**
 267       * The numerical options control the display and the grading of the unit
 268       * part of the numerical question and related types (calculateds)
 269       * Questions previous to 2.0 do not have this table as multianswer questions
 270       * in all versions including 2.0. The default values are set to give the same grade
 271       * as old question.
 272       *
 273       */
 274      public function save_unit_options($question) {
 275          global $DB;
 276          $result = new stdClass();
 277  
 278          $update = true;
 279          $options = $DB->get_record('question_numerical_options',
 280                  array('question' => $question->id));
 281          if (!$options) {
 282              $options = new stdClass();
 283              $options->question = $question->id;
 284              $options->id = $DB->insert_record('question_numerical_options', $options);
 285          }
 286  
 287          if (isset($question->unitpenalty)) {
 288              $options->unitpenalty = $question->unitpenalty;
 289          } else {
 290              // Either an old question or a close question type.
 291              $options->unitpenalty = 1;
 292          }
 293  
 294          $options->unitgradingtype = 0;
 295          if (isset($question->unitrole)) {
 296              // Saving the editing form.
 297              $options->showunits = $question->unitrole;
 298              if ($question->unitrole == self::UNITGRADED) {
 299                  $options->unitgradingtype = $question->unitgradingtypes;
 300                  $options->showunits = $question->multichoicedisplay;
 301              }
 302  
 303          } else if (isset($question->showunits)) {
 304              // Updated import, e.g. Moodle XML.
 305              $options->showunits = $question->showunits;
 306              if (isset($question->unitgradingtype)) {
 307                  $options->unitgradingtype = $question->unitgradingtype;
 308              }
 309          } else {
 310              // Legacy import.
 311              if ($defaultunit = $this->get_default_numerical_unit($question)) {
 312                  $options->showunits = self::UNITINPUT;
 313              } else {
 314                  $options->showunits = self::UNITNONE;
 315              }
 316          }
 317  
 318          $options->unitsleft = !empty($question->unitsleft);
 319  
 320          $DB->update_record('question_numerical_options', $options);
 321  
 322          // Report any problems.
 323          if (!empty($result->notice)) {
 324              return $result;
 325          }
 326  
 327          return true;
 328      }
 329  
 330      public function save_units($question) {
 331          global $DB;
 332          $result = new stdClass();
 333  
 334          // Delete the units previously saved for this question.
 335          $DB->delete_records('question_numerical_units', array('question' => $question->id));
 336  
 337          // Nothing to do.
 338          if (!isset($question->multiplier)) {
 339              $result->units = array();
 340              return $result;
 341          }
 342  
 343          // Save the new units.
 344          $units = array();
 345          $unitalreadyinsert = array();
 346          foreach ($question->multiplier as $i => $multiplier) {
 347              // Discard any unit which doesn't specify the unit or the multiplier.
 348              if (!empty($question->multiplier[$i]) && !empty($question->unit[$i]) &&
 349                      !array_key_exists($question->unit[$i], $unitalreadyinsert)) {
 350                  $unitalreadyinsert[$question->unit[$i]] = 1;
 351                  $units[$i] = new stdClass();
 352                  $units[$i]->question = $question->id;
 353                  $units[$i]->multiplier = $this->apply_unit($question->multiplier[$i],
 354                          array(), false);
 355                  $units[$i]->unit = $question->unit[$i];
 356                  $DB->insert_record('question_numerical_units', $units[$i]);
 357              }
 358          }
 359          unset($question->multiplier, $question->unit);
 360  
 361          $result->units = &$units;
 362          return $result;
 363      }
 364  
 365      protected function initialise_question_instance(question_definition $question, $questiondata) {
 366          parent::initialise_question_instance($question, $questiondata);
 367          $this->initialise_numerical_answers($question, $questiondata);
 368          $question->unitdisplay = $questiondata->options->showunits;
 369          $question->unitgradingtype = $questiondata->options->unitgradingtype;
 370          $question->unitpenalty = $questiondata->options->unitpenalty;
 371          $question->unitsleft = $questiondata->options->unitsleft;
 372          $question->ap = $this->make_answer_processor($questiondata->options->units,
 373                  $questiondata->options->unitsleft);
 374      }
 375  
 376      public function initialise_numerical_answers(question_definition $question, $questiondata) {
 377          $question->answers = array();
 378          if (empty($questiondata->options->answers)) {
 379              return;
 380          }
 381          foreach ($questiondata->options->answers as $a) {
 382              $question->answers[$a->id] = new qtype_numerical_answer($a->id, $a->answer,
 383                      $a->fraction, $a->feedback, $a->feedbackformat, $a->tolerance);
 384          }
 385      }
 386  
 387      public function make_answer_processor($units, $unitsleft) {
 388          if (empty($units)) {
 389              return new qtype_numerical_answer_processor(array());
 390          }
 391  
 392          $cleanedunits = array();
 393          foreach ($units as $unit) {
 394              $cleanedunits[$unit->unit] = $unit->multiplier;
 395          }
 396  
 397          return new qtype_numerical_answer_processor($cleanedunits, $unitsleft);
 398      }
 399  
 400      public function delete_question($questionid, $contextid) {
 401          global $DB;
 402          $DB->delete_records('question_numerical', array('question' => $questionid));
 403          $DB->delete_records('question_numerical_options', array('question' => $questionid));
 404          $DB->delete_records('question_numerical_units', array('question' => $questionid));
 405  
 406          parent::delete_question($questionid, $contextid);
 407      }
 408  
 409      public function get_random_guess_score($questiondata) {
 410          foreach ($questiondata->options->answers as $aid => $answer) {
 411              if ('*' == trim($answer->answer)) {
 412                  return max($answer->fraction - $questiondata->options->unitpenalty, 0);
 413              }
 414          }
 415          return 0;
 416      }
 417  
 418      /**
 419       * Add a unit to a response for display.
 420       * @param object $questiondata the data defining the quetsion.
 421       * @param string $answer a response.
 422       * @param object $unit a unit. If null, {@link get_default_numerical_unit()}
 423       * is used.
 424       */
 425      public function add_unit($questiondata, $answer, $unit = null) {
 426          if (is_null($unit)) {
 427              $unit = $this->get_default_numerical_unit($questiondata);
 428          }
 429  
 430          if (!$unit) {
 431              return $answer;
 432          }
 433  
 434          if (!empty($questiondata->options->unitsleft)) {
 435              return $unit->unit . ' ' . $answer;
 436          } else {
 437              return $answer . ' ' . $unit->unit;
 438          }
 439      }
 440  
 441      public function get_possible_responses($questiondata) {
 442          $responses = array();
 443  
 444          $unit = $this->get_default_numerical_unit($questiondata);
 445  
 446          $starfound = false;
 447          foreach ($questiondata->options->answers as $aid => $answer) {
 448              $responseclass = $answer->answer;
 449  
 450              if ($responseclass === '*') {
 451                  $starfound = true;
 452              } else {
 453                  $responseclass = $this->add_unit($questiondata, $responseclass, $unit);
 454  
 455                  $ans = new qtype_numerical_answer($answer->id, $answer->answer, $answer->fraction,
 456                          $answer->feedback, $answer->feedbackformat, $answer->tolerance);
 457                  list($min, $max) = $ans->get_tolerance_interval();
 458                  $responseclass .= " ({$min}..{$max})";
 459              }
 460  
 461              $responses[$aid] = new question_possible_response($responseclass,
 462                      $answer->fraction);
 463          }
 464  
 465          if (!$starfound) {
 466              $responses[0] = new question_possible_response(
 467                      get_string('didnotmatchanyanswer', 'question'), 0);
 468          }
 469  
 470          $responses[null] = question_possible_response::no_response();
 471  
 472          return array($questiondata->id => $responses);
 473      }
 474  
 475      /**
 476       * Checks if the $rawresponse has a unit and applys it if appropriate.
 477       *
 478       * @param string $rawresponse  The response string to be converted to a float.
 479       * @param array $units         An array with the defined units, where the
 480       *                             unit is the key and the multiplier the value.
 481       * @return float               The rawresponse with the unit taken into
 482       *                             account as a float.
 483       */
 484      public function apply_unit($rawresponse, $units, $unitsleft) {
 485          $ap = $this->make_answer_processor($units, $unitsleft);
 486          list($value, $unit, $multiplier) = $ap->apply_units($rawresponse);
 487          if (!is_null($multiplier)) {
 488              $value *= $multiplier;
 489          }
 490          return $value;
 491      }
 492  
 493      public function move_files($questionid, $oldcontextid, $newcontextid) {
 494          $fs = get_file_storage();
 495  
 496          parent::move_files($questionid, $oldcontextid, $newcontextid);
 497          $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid);
 498          $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
 499      }
 500  
 501      protected function delete_files($questionid, $contextid) {
 502          $fs = get_file_storage();
 503  
 504          parent::delete_files($questionid, $contextid);
 505          $this->delete_files_in_answers($questionid, $contextid);
 506          $this->delete_files_in_hints($questionid, $contextid);
 507      }
 508  }
 509  
 510  
 511  /**
 512   * This class processes numbers with units.
 513   *
 514   * @copyright 2010 The Open University
 515   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 516   */
 517  class qtype_numerical_answer_processor {
 518      /** @var array unit name => multiplier. */
 519      protected $units;
 520      /** @var string character used as decimal point. */
 521      protected $decsep;
 522      /** @var string character used as thousands separator. */
 523      protected $thousandssep;
 524      /** @var boolean whether the units come before or after the number. */
 525      protected $unitsbefore;
 526  
 527      protected $regex = null;
 528  
 529      public function __construct($units, $unitsbefore = false, $decsep = null,
 530              $thousandssep = null) {
 531          if (is_null($decsep)) {
 532              $decsep = get_string('decsep', 'langconfig');
 533          }
 534          $this->decsep = $decsep;
 535  
 536          if (is_null($thousandssep)) {
 537              $thousandssep = get_string('thousandssep', 'langconfig');
 538          }
 539          $this->thousandssep = $thousandssep;
 540  
 541          $this->units = $units;
 542          $this->unitsbefore = $unitsbefore;
 543      }
 544  
 545      /**
 546       * Set the decimal point and thousands separator character that should be used.
 547       * @param string $decsep
 548       * @param string $thousandssep
 549       */
 550      public function set_characters($decsep, $thousandssep) {
 551          $this->decsep = $decsep;
 552          $this->thousandssep = $thousandssep;
 553          $this->regex = null;
 554      }
 555  
 556      /** @return string the decimal point character used. */
 557      public function get_point() {
 558          return $this->decsep;
 559      }
 560  
 561      /** @return string the thousands separator character used. */
 562      public function get_separator() {
 563          return $this->thousandssep;
 564      }
 565  
 566      /**
 567       * @return bool If the student's response contains a '.' or a ',' that
 568       * matches the thousands separator in the current locale. In this case, the
 569       * parsing in apply_unit can give a result that the student did not expect.
 570       */
 571      public function contains_thousands_seaparator($value) {
 572          if (!in_array($this->thousandssep, array('.', ','))) {
 573              return false;
 574          }
 575  
 576          return strpos($value, $this->thousandssep) !== false;
 577      }
 578  
 579      /**
 580       * Create the regular expression that {@link parse_response()} requires.
 581       * @return string
 582       */
 583      protected function build_regex() {
 584          if (!is_null($this->regex)) {
 585              return $this->regex;
 586          }
 587  
 588          $decsep = preg_quote($this->decsep, '/');
 589          $thousandssep = preg_quote($this->thousandssep, '/');
 590          $beforepointre = '([+-]?[' . $thousandssep . '\d]*)';
 591          $decimalsre = $decsep . '(\d*)';
 592          $exponentre = '(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)';
 593  
 594          $numberbit = "{$beforepointre}(?:{$decimalsre})?(?:{$exponentre})?";
 595  
 596          if ($this->unitsbefore) {
 597              $this->regex = "/{$numberbit}$/";
 598          } else {
 599              $this->regex = "/^{$numberbit}/";
 600          }
 601          return $this->regex;
 602      }
 603  
 604      /**
 605       * This method can be used for more locale-strict parsing of repsonses. At the
 606       * moment we don't use it, and instead use the more lax parsing in apply_units.
 607       * This is just a note that this funciton was used in the past, so if you are
 608       * intersted, look through version control history.
 609       *
 610       * Take a string which is a number with or without a decimal point and exponent,
 611       * and possibly followed by one of the units, and split it into bits.
 612       * @param string $response a value, optionally with a unit.
 613       * @return array four strings (some of which may be blank) the digits before
 614       * and after the decimal point, the exponent, and the unit. All four will be
 615       * null if the response cannot be parsed.
 616       */
 617      protected function parse_response($response) {
 618          if (!preg_match($this->build_regex(), $response, $matches)) {
 619              return array(null, null, null, null);
 620          }
 621  
 622          $matches += array('', '', '', ''); // Fill in any missing matches.
 623          list($matchedpart, $beforepoint, $decimals, $exponent) = $matches;
 624  
 625          // Strip out thousands separators.
 626          $beforepoint = str_replace($this->thousandssep, '', $beforepoint);
 627  
 628          // Must be either something before, or something after the decimal point.
 629          // (The only way to do this in the regex would make it much more complicated.)
 630          if ($beforepoint === '' && $decimals === '') {
 631              return array(null, null, null, null);
 632          }
 633  
 634          if ($this->unitsbefore) {
 635              $unit = substr($response, 0, -strlen($matchedpart));
 636          } else {
 637              $unit = substr($response, strlen($matchedpart));
 638          }
 639          $unit = trim($unit);
 640  
 641          return array($beforepoint, $decimals, $exponent, $unit);
 642      }
 643  
 644      /**
 645       * Takes a number in almost any localised form, and possibly with a unit
 646       * after it. It separates off the unit, if present, and converts to the
 647       * default unit, by using the given unit multiplier.
 648       *
 649       * @param string $response a value, optionally with a unit.
 650       * @return array(numeric, string, multiplier) the value with the unit stripped, and normalised
 651       *      by the unit multiplier, if any, and the unit string, for reference.
 652       */
 653      public function apply_units($response, $separateunit = null): array {
 654          if ($response === null || trim($response) === '') {
 655              return [null, null, null];
 656          }
 657  
 658          // Strip spaces (which may be thousands separators) and change other forms
 659          // of writing e to e.
 660          $response = str_replace(' ', '', $response);
 661          $response = preg_replace('~(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)~', 'e$1', $response);
 662  
 663          // If a . is present or there are multiple , (i.e. 2,456,789 ) assume ,
 664          // is a thouseands separator, and strip it, else assume it is a decimal
 665          // separator, and change it to ..
 666          if (strpos($response, '.') !== false || substr_count($response, ',') > 1) {
 667              $response = str_replace(',', '', $response);
 668          } else {
 669              $response = str_replace([$this->thousandssep, $this->decsep, ','], ['', '.', '.'], $response);
 670          }
 671  
 672          $regex = '[+-]?(?:\d+(?:\\.\d*)?|\\.\d+)(?:e[-+]?\d+)?';
 673          if ($this->unitsbefore) {
 674              $regex = "/{$regex}$/";
 675          } else {
 676              $regex = "/^{$regex}/";
 677          }
 678          if (!preg_match($regex, $response, $matches)) {
 679              return array(null, null, null);
 680          }
 681  
 682          $numberstring = $matches[0];
 683          if ($this->unitsbefore) {
 684              // Substr returns false when it means '', so cast back to string.
 685              $unit = (string) substr($response, 0, -strlen($numberstring));
 686          } else {
 687              $unit = (string) substr($response, strlen($numberstring));
 688          }
 689  
 690          if (!is_null($separateunit)) {
 691              $unit = $separateunit;
 692          }
 693  
 694          if ($this->is_known_unit($unit)) {
 695              $multiplier = 1 / $this->units[$unit];
 696          } else {
 697              $multiplier = null;
 698          }
 699  
 700          return array($numberstring + 0, $unit, $multiplier); // The + 0 is to convert to number.
 701      }
 702  
 703      /**
 704       * @return string the default unit.
 705       */
 706      public function get_default_unit() {
 707          reset($this->units);
 708          return key($this->units);
 709      }
 710  
 711      /**
 712       * @param string $answer a response.
 713       * @param string $unit a unit.
 714       */
 715      public function add_unit($answer, $unit = null) {
 716          if (is_null($unit)) {
 717              $unit = $this->get_default_unit();
 718          }
 719  
 720          if (!$unit) {
 721              return $answer;
 722          }
 723  
 724          if ($this->unitsbefore) {
 725              return $unit . ' ' . $answer;
 726          } else {
 727              return $answer . ' ' . $unit;
 728          }
 729      }
 730  
 731      /**
 732       * Is this unit recognised.
 733       * @param string $unit the unit
 734       * @return bool whether this is a unit we recognise.
 735       */
 736      public function is_known_unit($unit) {
 737          return array_key_exists($unit, $this->units);
 738      }
 739  
 740      /**
 741       * Whether the units go before or after the number.
 742       * @return true = before, false = after.
 743       */
 744      public function are_units_before() {
 745          return $this->unitsbefore;
 746      }
 747  
 748      /**
 749       * Get the units as an array suitably for passing to html_writer::select.
 750       * @return array of unit choices.
 751       */
 752      public function get_unit_options() {
 753          $options = array();
 754          foreach ($this->units as $unit => $notused) {
 755              $options[$unit] = $unit;
 756          }
 757          return $options;
 758      }
 759  }