[ Index ]

PHP Cross Reference of Unnamed Project

title

Body

[close]

/question/type/calculated/ -> question.php (source)

   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   * Calculated question definition class.
  19   *
  20   * @package    qtype
  21   * @subpackage calculated
  22   * @copyright  2011 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  require_once($CFG->dirroot . '/question/type/numerical/question.php');
  31  
  32  /**
  33   * Represents a calculated question.
  34   *
  35   * @copyright  2011 The Open University
  36   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  37   */
  38  class qtype_calculated_question extends qtype_numerical_question
  39          implements qtype_calculated_question_with_expressions {
  40  
  41      /** @var qtype_calculated_dataset_loader helper for loading the dataset. */
  42      public $datasetloader;
  43  
  44      /** @var qtype_calculated_variable_substituter stores the dataset we are using. */
  45      public $vs;
  46  
  47      /**
  48       * @var bool wheter the dataset item to use should be chose based on attempt
  49       * start time, rather than randomly.
  50       */
  51      public $synchronised;
  52  
  53      public function start_attempt(question_attempt_step $step, $variant) {
  54          qtype_calculated_question_helper::start_attempt($this, $step, $variant);
  55          parent::start_attempt($step, $variant);
  56      }
  57  
  58      public function apply_attempt_state(question_attempt_step $step) {
  59          qtype_calculated_question_helper::apply_attempt_state($this, $step);
  60          parent::apply_attempt_state($step);
  61      }
  62  
  63      public function calculate_all_expressions() {
  64          $this->questiontext = $this->vs->replace_expressions_in_text($this->questiontext);
  65          $this->generalfeedback = $this->vs->replace_expressions_in_text($this->generalfeedback);
  66  
  67          foreach ($this->answers as $ans) {
  68              if ($ans->answer && $ans->answer !== '*') {
  69                  $ans->answer = $this->vs->calculate($ans->answer,
  70                          $ans->correctanswerlength, $ans->correctanswerformat);
  71              }
  72              $ans->feedback = $this->vs->replace_expressions_in_text($ans->feedback,
  73                          $ans->correctanswerlength, $ans->correctanswerformat);
  74          }
  75      }
  76  
  77      public function get_num_variants() {
  78          return $this->datasetloader->get_number_of_items();
  79      }
  80  
  81      public function get_variants_selection_seed() {
  82          if (!empty($this->synchronised) &&
  83                  $this->datasetloader->datasets_are_synchronised($this->category)) {
  84              return 'category' . $this->category;
  85          } else {
  86              return parent::get_variants_selection_seed();
  87          }
  88      }
  89  
  90      public function get_correct_response() {
  91          $answer = $this->get_correct_answer();
  92          if (!$answer) {
  93              return array();
  94          }
  95  
  96          $response = array('answer' => $this->vs->format_float($answer->answer,
  97              $answer->correctanswerlength, $answer->correctanswerformat));
  98  
  99          if ($this->has_separate_unit_field()) {
 100              $response['unit'] = $this->ap->get_default_unit();
 101          } else if ($this->unitdisplay == qtype_numerical::UNITINPUT) {
 102              $response['answer'] = $this->ap->add_unit($response['answer']);
 103          }
 104  
 105          return $response;
 106      }
 107  
 108  }
 109  
 110  
 111  /**
 112   * This interface defines the method that a quetsion type must implement if it
 113   * is to work with {@link qtype_calculated_question_helper}.
 114   *
 115   * As well as this method, the class that implements this interface must have
 116   * fields
 117   * public $datasetloader; // of type qtype_calculated_dataset_loader
 118   * public $vs; // of type qtype_calculated_variable_substituter
 119   *
 120   * @copyright  2011 The Open University
 121   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 122   */
 123  interface qtype_calculated_question_with_expressions {
 124      /**
 125       * Replace all the expression in the question definition with the values
 126       * computed from the selected dataset by calling $this->vs->calculate() and
 127       * $this->vs->replace_expressions_in_text() on the parts of the question
 128       * that require it.
 129       */
 130      public function calculate_all_expressions();
 131  }
 132  
 133  
 134  /**
 135   * Helper class for questions that use datasets. Works with the interface
 136   * {@link qtype_calculated_question_with_expressions} and the class
 137   * {@link qtype_calculated_dataset_loader} to set up the value of each variable
 138   * in start_attempt, and restore that in apply_attempt_state.
 139   *
 140   * @copyright  2011 The Open University
 141   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 142   */
 143  abstract class qtype_calculated_question_helper {
 144      public static function start_attempt(
 145              qtype_calculated_question_with_expressions $question,
 146              question_attempt_step $step, $variant) {
 147  
 148          $question->vs = new qtype_calculated_variable_substituter(
 149                  $question->datasetloader->get_values($variant),
 150                  get_string('decsep', 'langconfig'));
 151          $question->calculate_all_expressions();
 152  
 153          foreach ($question->vs->get_values() as $name => $value) {
 154              $step->set_qt_var('_var_' . $name, $value);
 155          }
 156      }
 157  
 158      public static function apply_attempt_state(
 159              qtype_calculated_question_with_expressions $question, question_attempt_step $step) {
 160          $values = array();
 161          foreach ($step->get_qt_data() as $name => $value) {
 162              if (substr($name, 0, 5) === '_var_') {
 163                  $values[substr($name, 5)] = $value;
 164              }
 165          }
 166  
 167          $question->vs = new qtype_calculated_variable_substituter(
 168                  $values, get_string('decsep', 'langconfig'));
 169          $question->calculate_all_expressions();
 170      }
 171  }
 172  
 173  
 174  /**
 175   * This class is responsible for loading the dataset that a question needs from
 176   * the database.
 177   *
 178   * @copyright  2011 The Open University
 179   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 180   */
 181  class qtype_calculated_dataset_loader {
 182      /** @var int the id of the question we are helping. */
 183      protected $questionid;
 184  
 185      /** @var int the id of the question we are helping. */
 186      protected $itemsavailable = null;
 187  
 188      /**
 189       * Constructor
 190       * @param int $questionid the question to load datasets for.
 191       */
 192      public function __construct($questionid) {
 193          $this->questionid = $questionid;
 194      }
 195  
 196      /**
 197       * Get the number of items (different values) in each dataset used by this
 198       * question. This is the minimum number of items in any dataset used by this
 199       * question.
 200       * @return int the number of items available.
 201       */
 202      public function get_number_of_items() {
 203          global $DB;
 204  
 205          if (is_null($this->itemsavailable)) {
 206              $this->itemsavailable = $DB->get_field_sql('
 207                      SELECT MIN(qdd.itemcount)
 208                        FROM {question_dataset_definitions} qdd
 209                        JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
 210                       WHERE qd.question = ?
 211                      ', array($this->questionid), MUST_EXIST);
 212          }
 213  
 214          return $this->itemsavailable;
 215      }
 216  
 217      /**
 218       * Actually query the database for the values.
 219       * @param int $itemnumber which set of values to load.
 220       * @return array name => value;
 221       */
 222      protected function load_values($itemnumber) {
 223          global $DB;
 224  
 225          return $DB->get_records_sql_menu('
 226                  SELECT qdd.name, qdi.value
 227                    FROM {question_dataset_items} qdi
 228                    JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
 229                    JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
 230                   WHERE qd.question = ?
 231                     AND qdi.itemnumber = ?
 232                  ', array($this->questionid, $itemnumber));
 233      }
 234  
 235      /**
 236       * Load a particular set of values for each dataset used by this question.
 237       * @param int $itemnumber which set of values to load.
 238       *      0 < $itemnumber <= {@link get_number_of_items()}.
 239       * @return array name => value.
 240       */
 241      public function get_values($itemnumber) {
 242          if ($itemnumber <= 0 || $itemnumber > $this->get_number_of_items()) {
 243              $a = new stdClass();
 244              $a->id = $this->questionid;
 245              $a->item = $itemnumber;
 246              throw new moodle_exception('cannotgetdsfordependent', 'question', '', $a);
 247          }
 248  
 249          return $this->load_values($itemnumber);
 250      }
 251  
 252      public function datasets_are_synchronised($category) {
 253          global $DB;
 254          // We need to ensure that there are synchronised datasets, and that they
 255          // all use the right category.
 256          $categories = $DB->get_record_sql('
 257                  SELECT MAX(qdd.category) AS max,
 258                         MIN(qdd.category) AS min
 259                    FROM {question_dataset_definitions} qdd
 260                    JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
 261                   WHERE qd.question = ?
 262                     AND qdd.category <> 0
 263              ', array($this->questionid));
 264  
 265          return $categories && $categories->max == $category && $categories->min == $category;
 266      }
 267  }
 268  
 269  
 270  /**
 271   * This class holds the current values of all the variables used by a calculated
 272   * question.
 273   *
 274   * It can compute formulae using those values, and can substitute equations
 275   * embedded in text.
 276   *
 277   * @copyright  2011 The Open University
 278   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 279   */
 280  class qtype_calculated_variable_substituter {
 281  
 282      /** @var array variable name => value */
 283      protected $values;
 284  
 285      /** @var string character to use for the decimal point in displayed numbers. */
 286      protected $decimalpoint;
 287  
 288      /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */
 289      protected $search;
 290  
 291      /**
 292       * @var array variable values, with negative numbers wrapped in (...).
 293       * Used by {@link substitute_values()}.
 294       */
 295      protected $safevalue;
 296  
 297      /**
 298       * @var array variable values, with negative numbers wrapped in (...).
 299       * Used by {@link substitute_values()}.
 300       */
 301      protected $prettyvalue;
 302  
 303      /**
 304       * Constructor
 305       * @param array $values variable name => value.
 306       */
 307      public function __construct(array $values, $decimalpoint) {
 308          $this->values = $values;
 309          $this->decimalpoint = $decimalpoint;
 310  
 311          // Prepare an array for {@link substitute_values()}.
 312          $this->search = array();
 313          $this->replace = array();
 314          foreach ($values as $name => $value) {
 315              if (!is_numeric($value)) {
 316                  $a = new stdClass();
 317                  $a->name = '{' . $name . '}';
 318                  $a->value = $value;
 319                  throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a);
 320              }
 321  
 322              $this->search[] = '{' . $name . '}';
 323              $this->safevalue[] = '(' . $value . ')';
 324              $this->prettyvalue[] = $this->format_float($value);
 325          }
 326      }
 327  
 328      /**
 329       * Display a float properly formatted with a certain number of decimal places.
 330       * @param number $x the number to format
 331       * @param int $length restrict to this many decimal places or significant
 332       *      figures. If null, the number is not rounded.
 333       * @param int format 1 => decimalformat, 2 => significantfigures.
 334       * @return string formtted number.
 335       */
 336      public function format_float($x, $length = null, $format = null) {
 337          if (!is_null($length) && !is_null($format)) {
 338              if ($format == '1' ) { // Answer is to have $length decimals.
 339                  // Decimal places.
 340                  $x = sprintf('%.' . $length . 'F', $x);
 341  
 342              } else if ($x) { // Significant figures does only apply if the result is non-zero.
 343                  $answer = $x;
 344                  // Convert to positive answer.
 345                  if ($answer < 0) {
 346                      $answer = -$answer;
 347                      $sign = '-';
 348                  } else {
 349                      $sign = '';
 350                  }
 351  
 352                  // Determine the format 0.[1-9][0-9]* for the answer...
 353                  $p10 = 0;
 354                  while ($answer < 1) {
 355                      --$p10;
 356                      $answer *= 10;
 357                  }
 358                  while ($answer >= 1) {
 359                      ++$p10;
 360                      $answer /= 10;
 361                  }
 362                  // ... and have the answer rounded of to the correct length.
 363                  $answer = round($answer, $length);
 364  
 365                  // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format.
 366                  if ($answer >= 1) {
 367                      ++$p10;
 368                      $answer /= 10;
 369                  }
 370  
 371                  // Have the answer written on a suitable format.
 372                  // Either scientific or plain numeric.
 373                  if (-2 > $p10 || 4 < $p10) {
 374                      // Use scientific format.
 375                      $exponent = 'e'.--$p10;
 376                      $answer *= 10;
 377                      if (1 == $length) {
 378                          $x = $sign.$answer.$exponent;
 379                      } else {
 380                          // Attach additional zeros at the end of $answer.
 381                          $answer .= (1 == strlen($answer) ? '.' : '')
 382                              . '00000000000000000000000000000000000000000x';
 383                          $x = $sign
 384                              .substr($answer, 0, $length +1).$exponent;
 385                      }
 386                  } else {
 387                      // Stick to plain numeric format.
 388                      $answer *= "1e{$p10}";
 389                      if (0.1 <= $answer / "1e{$length}") {
 390                          $x = $sign.$answer;
 391                      } else {
 392                          // Could be an idea to add some zeros here.
 393                          $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '')
 394                              . '00000000000000000000000000000000000000000x';
 395                          $oklen = $length + ($p10 < 1 ? 2-$p10 : 1);
 396                          $x = $sign.substr($answer, 0, $oklen);
 397                      }
 398                  }
 399  
 400              } else {
 401                  $x = 0.0;
 402              }
 403          }
 404          return str_replace('.', $this->decimalpoint, $x);
 405      }
 406  
 407      /**
 408       * Return an array of the variables and their values.
 409       * @return array name => value.
 410       */
 411      public function get_values() {
 412          return $this->values;
 413      }
 414  
 415      /**
 416       * Evaluate an expression using the variable values.
 417       * @param string $expression the expression. A PHP expression with placeholders
 418       *      like {a} for where the variables need to go.
 419       * @return float the computed result.
 420       */
 421      public function calculate($expression) {
 422          // Make sure no malicious code is present in the expression. Refer MDL-46148 for details.
 423          if ($error = qtype_calculated_find_formula_errors($expression)) {
 424              throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $error);
 425          }
 426          return $this->calculate_raw($this->substitute_values_for_eval($expression));
 427      }
 428  
 429      /**
 430       * Evaluate an expression after the variable values have been substituted.
 431       * @param string $expression the expression. A PHP expression with placeholders
 432       *      like {a} for where the variables need to go.
 433       * @return float the computed result.
 434       */
 435      protected function calculate_raw($expression) {
 436          try {
 437              // In older PHP versions this this is a way to validate code passed to eval.
 438              // The trick came from http://php.net/manual/en/function.eval.php.
 439              if (@eval('return true; $result = ' . $expression . ';')) {
 440                  return eval('return ' . $expression . ';');
 441              }
 442          } catch (Throwable $e) {
 443              // PHP7 and later now throws ParseException and friends from eval(),
 444              // which is much better.
 445          }
 446          // In either case of an invalid $expression, we end here.
 447          throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression);
 448      }
 449  
 450      /**
 451       * Substitute variable placehodlers like {a} with their value wrapped in ().
 452       * @param string $expression the expression. A PHP expression with placeholders
 453       *      like {a} for where the variables need to go.
 454       * @return string the expression with each placeholder replaced by the
 455       *      corresponding value.
 456       */
 457      protected function substitute_values_for_eval($expression) {
 458          return str_replace($this->search, $this->safevalue, $expression);
 459      }
 460  
 461      /**
 462       * Substitute variable placehodlers like {a} with their value without wrapping
 463       * the value in anything.
 464       * @param string $text some content with placeholders
 465       *      like {a} for where the variables need to go.
 466       * @return string the expression with each placeholder replaced by the
 467       *      corresponding value.
 468       */
 469      protected function substitute_values_pretty($text) {
 470          return str_replace($this->search, $this->prettyvalue, $text);
 471      }
 472  
 473      /**
 474       * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}})
 475       * in some text with the corresponding values.
 476       * @param string $text the text to process.
 477       * @return string the text with values substituted.
 478       */
 479      public function replace_expressions_in_text($text, $length = null, $format = null) {
 480          $vs = $this; // Can't use $this in a PHP closure.
 481          $text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX,
 482                  function ($matches) use ($vs, $format, $length) {
 483                      return $vs->format_float($vs->calculate($matches[1]), $length, $format);
 484                  }, $text);
 485          return $this->substitute_values_pretty($text);
 486      }
 487  }


Generated: Thu Aug 11 10:00:09 2016 Cross-referenced by PHPXref 0.7.1