71b25a6790bbbbbfa29d65b1080cf37c926ca6aa
[moodle.git] / question / type / calculated / question.php
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/>.
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  */
27 defined('MOODLE_INTERNAL') || die();
29 require_once($CFG->dirroot . '/question/type/numerical/question.php');
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 {
41     /** @var qtype_calculated_dataset_loader helper for loading the dataset. */
42     public $datasetloader;
44     /** @var qtype_calculated_variable_substituter stores the dataset we are using. */
45     public $vs;
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;
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     }
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     }
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);
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     }
77     public function get_num_variants() {
78         return $this->datasetloader->get_number_of_items();
79     }
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     }
90     public function get_correct_response() {
91         $answer = $this->get_correct_answer();
92         if (!$answer) {
93             return array();
94         }
96         $response = array('answer' => $this->vs->format_float($answer->answer,
97             $answer->correctanswerlength, $answer->correctanswerformat));
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         }
105         return $response;
106     }
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();
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) {
148         $question->vs = new qtype_calculated_variable_substituter(
149                 $question->datasetloader->get_values($variant),
150                 get_string('decsep', 'langconfig'));
151         $question->calculate_all_expressions();
153         foreach ($question->vs->get_values() as $name => $value) {
154             $step->set_qt_var('_var_' . $name, $value);
155         }
156     }
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         }
167         $question->vs = new qtype_calculated_variable_substituter(
168                 $values, get_string('decsep', 'langconfig'));
169         $question->calculate_all_expressions();
170     }
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;
185     /** @var int the id of the question we are helping. */
186     protected $itemsavailable = null;
188     /**
189      * Constructor
190      * @param int $questionid the question to load datasets for.
191      */
192     public function __construct($questionid) {
193         $this->questionid = $questionid;
194     }
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;
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         }
214         return $this->itemsavailable;
215     }
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;
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     }
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         }
249         return $this->load_values($itemnumber);
250     }
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));
265         return $categories && $categories->max == $category && $categories->min == $category;
266     }
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     /** @var array variable name => value */
282     protected $values;
284     /** @var string character to use for the decimal point in displayed numbers. */
285     protected $decimalpoint;
287     /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */
288     protected $search;
290     /**
291      * @var array variable values, with negative numbers wrapped in (...).
292      * Used by {@link substitute_values()}.
293      */
294     protected $safevalue;
296     /**
297      * @var array variable values, with negative numbers wrapped in (...).
298      * Used by {@link substitute_values()}.
299      */
300     protected $prettyvalue;
302     /**
303      * Constructor
304      * @param array $values variable name => value.
305      */
306     public function __construct(array $values, $decimalpoint) {
307         $this->values = $values;
308         $this->decimalpoint = $decimalpoint;
310         // Prepare an array for {@link substitute_values()}.
311         $this->search = array();
312         $this->replace = array();
313         foreach ($values as $name => $value) {
314             if (!is_numeric($value)) {
315                 $a = new stdClass();
316                 $a->name = '{' . $name . '}';
317                 $a->value = $value;
318                 throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a);
319             }
321             $this->search[] = '{' . $name . '}';
322             $this->safevalue[] = '(' . $value . ')';
323             $this->prettyvalue[] = $this->format_float($value);
324         }
325     }
327     /**
328      * Display a float properly formatted with a certain number of decimal places.
329      * @param number $x the number to format
330      * @param int $length restrict to this many decimal places or significant
331      *      figures. If null, the number is not rounded.
332      * @param int format 1 => decimalformat, 2 => significantfigures.
333      * @return string formtted number.
334      */
335     public function format_float($x, $length = null, $format = null) {
336         if (!is_null($length) && !is_null($format)) {
337             if ($format == '1' ) { // Answer is to have $length decimals.
338                 // Decimal places.
339                 $x = sprintf('%.' . $length . 'F', $x);
341             } else if ($x) { // Significant figures does only apply if the result is non-zero.
342                 $answer = $x;
343                 // Convert to positive answer.
344                 if ($answer < 0) {
345                     $answer = -$answer;
346                     $sign = '-';
347                 } else {
348                     $sign = '';
349                 }
351                 // Determine the format 0.[1-9][0-9]* for the answer...
352                 $p10 = 0;
353                 while ($answer < 1) {
354                     --$p10;
355                     $answer *= 10;
356                 }
357                 while ($answer >= 1) {
358                     ++$p10;
359                     $answer /= 10;
360                 }
361                 // ... and have the answer rounded of to the correct length.
362                 $answer = round($answer, $length);
364                 // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format.
365                 if ($answer >= 1) {
366                     ++$p10;
367                     $answer /= 10;
368                 }
370                 // Have the answer written on a suitable format.
371                 // Either scientific or plain numeric.
372                 if (-2 > $p10 || 4 < $p10) {
373                     // Use scientific format.
374                     $exponent = 'e'.--$p10;
375                     $answer *= 10;
376                     if (1 == $length) {
377                         $x = $sign.$answer.$exponent;
378                     } else {
379                         // Attach additional zeros at the end of $answer.
380                         $answer .= (1 == strlen($answer) ? '.' : '')
381                             . '00000000000000000000000000000000000000000x';
382                         $x = $sign
383                             .substr($answer, 0, $length +1).$exponent;
384                     }
385                 } else {
386                     // Stick to plain numeric format.
387                     $answer *= "1e$p10";
388                     if (0.1 <= $answer / "1e$length") {
389                         $x = $sign.$answer;
390                     } else {
391                         // Could be an idea to add some zeros here.
392                         $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '')
393                             . '00000000000000000000000000000000000000000x';
394                         $oklen = $length + ($p10 < 1 ? 2-$p10 : 1);
395                         $x = $sign.substr($answer, 0, $oklen);
396                     }
397                 }
399             } else {
400                 $x = 0.0;
401             }
402         }
403         return str_replace('.', $this->decimalpoint, $x);
404     }
406     /**
407      * Return an array of the variables and their values.
408      * @return array name => value.
409      */
410     public function get_values() {
411         return $this->values;
412     }
414     /**
415      * Evaluate an expression using the variable values.
416      * @param string $expression the expression. A PHP expression with placeholders
417      *      like {a} for where the variables need to go.
418      * @return float the computed result.
419      */
420     public function calculate($expression) {
421         return $this->calculate_raw($this->substitute_values_for_eval($expression));
422     }
424     /**
425      * Evaluate an expression after the variable values have been substituted.
426      * @param string $expression the expression. A PHP expression with placeholders
427      *      like {a} for where the variables need to go.
428      * @return float the computed result.
429      */
430     protected function calculate_raw($expression) {
431         // This validation trick from http://php.net/manual/en/function.eval.php .
432         if (!@eval('return true; $result = ' . $expression . ';')) {
433             throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression);
434         }
435         return eval('return ' . $expression . ';');
436     }
438     /**
439      * Substitute variable placehodlers like {a} with their value wrapped in ().
440      * @param string $expression the expression. A PHP expression with placeholders
441      *      like {a} for where the variables need to go.
442      * @return string the expression with each placeholder replaced by the
443      *      corresponding value.
444      */
445     protected function substitute_values_for_eval($expression) {
446         return str_replace($this->search, $this->safevalue, $expression);
447     }
449     /**
450      * Substitute variable placehodlers like {a} with their value without wrapping
451      * the value in anything.
452      * @param string $text some content 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_pretty($text) {
458         return str_replace($this->search, $this->prettyvalue, $text);
459     }
461     /**
462      * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}})
463      * in some text with the corresponding values.
464      * @param string $text the text to process.
465      * @return string the text with values substituted.
466      */
467     public function replace_expressions_in_text($text, $length = null, $format = null) {
468         $vs = $this; // Can't see to use $this in a PHP closure.
469         $text = preg_replace_callback('~\{=([^{}]*(?:\{[^{}]+}[^{}]*)*)}~',
470                 function ($matches) use ($vs, $format, $length) {
471                     return $vs->format_float($vs->calculate($matches[1]), $length, $format);
472                 }, $text);
473         return $this->substitute_values_pretty($text);
474     }
476     /**
477      * Return an array describing any problems there are with an expression.
478      * Returns false if the expression is fine.
479      * @param string $formula an expression.
480      * @return array|false list of problems, or false if the exression is OK.
481      */
482     public function get_formula_errors($formula) {
483         // Validates the formula submitted from the question edit page.
484         // Returns false if everything is alright
485         // otherwise it constructs an error message.
486         // Strip away dataset names.
487         while (preg_match('~\\{[[:alpha:]][^>} <{"\']*\\}~', $formula, $regs)) {
488             $formula = str_replace($regs[0], '1', $formula);
489         }
491         // Strip away empty space and lowercase it.
492         $formula = strtolower(str_replace(' ', '', $formula));
494         $safeoperatorchar = '-+/*%>:^\~<?=&|!'; /* */
495         $operatorornumber = "[$safeoperatorchar.0-9eE]";
497         while (preg_match("~(^|[$safeoperatorchar,(])([a-z0-9_]*)" .
498                 "\\(($operatorornumber+(,$operatorornumber+((,$operatorornumber+)+)?)?)?\\)~",
499             $formula, $regs)) {
500             switch ($regs[2]) {
501                 // Simple parenthesis.
502                 case '':
503                     if ((isset($regs[4]) && $regs[4]) || strlen($regs[3]) == 0) {
504                         return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]);
505                     }
506                     break;
508                     // Zero argument functions.
509                 case 'pi':
510                     if ($regs[3]) {
511                         return get_string('functiontakesnoargs', 'qtype_calculated', $regs[2]);
512                     }
513                     break;
515                     // Single argument functions (the most common case).
516                 case 'abs': case 'acos': case 'acosh': case 'asin': case 'asinh':
517                 case 'atan': case 'atanh': case 'bindec': case 'ceil': case 'cos':
518                 case 'cosh': case 'decbin': case 'decoct': case 'deg2rad':
519                 case 'exp': case 'expm1': case 'floor': case 'is_finite':
520                 case 'is_infinite': case 'is_nan': case 'log10': case 'log1p':
521                 case 'octdec': case 'rad2deg': case 'sin': case 'sinh': case 'sqrt':
522                 case 'tan': case 'tanh':
523                     if (!empty($regs[4]) || empty($regs[3])) {
524                         return get_string('functiontakesonearg', 'qtype_calculated', $regs[2]);
525                     }
526                     break;
528                     // Functions that take one or two arguments.
529                 case 'log': case 'round':
530                     if (!empty($regs[5]) || empty($regs[3])) {
531                         return get_string('functiontakesoneortwoargs', 'qtype_calculated',
532                                 $regs[2]);
533                     }
534                     break;
536                     // Functions that must have two arguments.
537                 case 'atan2': case 'fmod': case 'pow':
538                     if (!empty($regs[5]) || empty($regs[4])) {
539                         return get_string('functiontakestwoargs', 'qtype_calculated', $regs[2]);
540                     }
541                     break;
543                     // Functions that take two or more arguments.
544                 case 'min': case 'max':
545                     if (empty($regs[4])) {
546                         return get_string('functiontakesatleasttwo', 'qtype_calculated', $regs[2]);
547                     }
548                     break;
550                 default:
551                     return get_string('unsupportedformulafunction', 'qtype_calculated', $regs[2]);
552             }
554             // Exchange the function call with '1' and then check for another function call.
556             if ($regs[1]) {
557                 // The function call is proceeded by an operator.
558                 $formula = str_replace($regs[0], $regs[1] . '1', $formula);
559             } else {
560                 // The function call starts the formula.
561                 $formula = preg_replace("~^$regs[2]\\([^)]*\\)~", '1', $formula);
562             }
563         }
565         if (preg_match("~[^$safeoperatorchar.0-9eE]+~", $formula, $regs)) {
566             return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]);
567         } else {
568             // Formula just might be valid.
569             return false;
570         }
571     }