MDL-46148 qtype_calculated: Remove unused function.
[moodle.git] / question / type / calculated / question.php
CommitLineData
06525476
TH
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
cdece95e 22 * @copyright 2011 The Open University
06525476
TH
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25
26
27defined('MOODLE_INTERNAL') || die();
28
29require_once($CFG->dirroot . '/question/type/numerical/question.php');
30
31
32/**
33 * Represents a calculated question.
34 *
cdece95e 35 * @copyright 2011 The Open University
06525476
TH
36 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37 */
cdece95e
TH
38class qtype_calculated_question extends qtype_numerical_question
39 implements qtype_calculated_question_with_expressions {
e35ba43c 40
06525476 41 /** @var qtype_calculated_dataset_loader helper for loading the dataset. */
1da4060f 42 public $datasetloader;
e35ba43c 43
06525476 44 /** @var qtype_calculated_variable_substituter stores the dataset we are using. */
1da4060f 45 public $vs;
06525476 46
e35ba43c
TH
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
1da821bb 53 public function start_attempt(question_attempt_step $step, $variant) {
c014b989 54 qtype_calculated_question_helper::start_attempt($this, $step, $variant);
1da821bb 55 parent::start_attempt($step, $variant);
cdece95e
TH
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 }
c014b989
TH
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) &&
409199d6 83 $this->datasetloader->datasets_are_synchronised($this->category)) {
c014b989
TH
84 return 'category' . $this->category;
85 } else {
86 return parent::get_variants_selection_seed();
87 }
88 }
9ddb8a56 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
cdece95e
TH
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
e0736817 116 * fields
cdece95e
TH
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 */
123interface 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 */
143abstract class qtype_calculated_question_helper {
144 public static function start_attempt(
c014b989
TH
145 qtype_calculated_question_with_expressions $question,
146 question_attempt_step $step, $variant) {
06525476 147
cdece95e 148 $question->vs = new qtype_calculated_variable_substituter(
c014b989 149 $question->datasetloader->get_values($variant),
1da4060f 150 get_string('decsep', 'langconfig'));
cdece95e 151 $question->calculate_all_expressions();
06525476 152
cdece95e 153 foreach ($question->vs->get_values() as $name => $value) {
06525476
TH
154 $step->set_qt_var('_var_' . $name, $value);
155 }
06525476
TH
156 }
157
cdece95e
TH
158 public static function apply_attempt_state(
159 qtype_calculated_question_with_expressions $question, question_attempt_step $step) {
06525476 160 $values = array();
18f9b2d2 161 foreach ($step->get_qt_data() as $name => $value) {
06525476
TH
162 if (substr($name, 0, 5) === '_var_') {
163 $values[substr($name, 5)] = $value;
164 }
165 }
06525476 166
cdece95e 167 $question->vs = new qtype_calculated_variable_substituter(
1da4060f 168 $values, get_string('decsep', 'langconfig'));
cdece95e 169 $question->calculate_all_expressions();
06525476
TH
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 */
181class 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. */
19e911a2 186 protected $itemsavailable = null;
06525476
TH
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
19e911a2
TH
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() {
06525476
TH
203 global $DB;
204
19e911a2
TH
205 if (is_null($this->itemsavailable)) {
206 $this->itemsavailable = $DB->get_field_sql('
06525476
TH
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 = ?
19e911a2 211 ', array($this->questionid), MUST_EXIST);
06525476
TH
212 }
213
19e911a2 214 return $this->itemsavailable;
06525476
TH
215 }
216
1da4060f
TH
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) {
18f9b2d2
TH
223 global $DB;
224
225 return $DB->get_records_sql_menu('
1da4060f
TH
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
06525476 235 /**
19e911a2
TH
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()}.
1da4060f 239 * @return array name => value.
06525476 240 */
1da4060f 241 public function get_values($itemnumber) {
19e911a2 242 if ($itemnumber <= 0 || $itemnumber > $this->get_number_of_items()) {
06525476 243 $a = new stdClass();
19e911a2
TH
244 $a->id = $this->questionid;
245 $a->item = $itemnumber;
06525476
TH
246 throw new moodle_exception('cannotgetdsfordependent', 'question', '', $a);
247 }
248
1da4060f 249 return $this->load_values($itemnumber);
06525476 250 }
e35ba43c
TH
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 }
06525476
TH
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 */
280class qtype_calculated_variable_substituter {
281 /** @var array variable name => value */
282 protected $values;
283
1da4060f
TH
284 /** @var string character to use for the decimal point in displayed numbers. */
285 protected $decimalpoint;
286
06525476
TH
287 /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */
288 protected $search;
289
290 /**
291 * @var array variable values, with negative numbers wrapped in (...).
292 * Used by {@link substitute_values()}.
293 */
1da4060f
TH
294 protected $safevalue;
295
296 /**
297 * @var array variable values, with negative numbers wrapped in (...).
298 * Used by {@link substitute_values()}.
299 */
300 protected $prettyvalue;
06525476
TH
301
302 /**
303 * Constructor
304 * @param array $values variable name => value.
305 */
1da4060f 306 public function __construct(array $values, $decimalpoint) {
06525476 307 $this->values = $values;
1da4060f 308 $this->decimalpoint = $decimalpoint;
06525476
TH
309
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 }
320
321 $this->search[] = '{' . $name . '}';
1da4060f
TH
322 $this->safevalue[] = '(' . $value . ')';
323 $this->prettyvalue[] = $this->format_float($value);
06525476
TH
324 }
325 }
326
1da4060f
TH
327 /**
328 * Display a float properly formatted with a certain number of decimal places.
01533e9c
TH
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.
1da4060f 334 */
cdece95e 335 public function format_float($x, $length = null, $format = null) {
3aa15970 336 if (!is_null($length) && !is_null($format)) {
3d9645ae 337 if ($format == '1' ) { // Answer is to have $length decimals.
cdece95e
TH
338 // Decimal places.
339 $x = sprintf('%.' . $length . 'F', $x);
9ddb8a56 340
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 }
350
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);
363
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 }
369
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 }
398
399 } else {
400 $x = 0.0;
cdece95e
TH
401 }
402 }
1da4060f
TH
403 return str_replace('.', $this->decimalpoint, $x);
404 }
405
06525476
TH
406 /**
407 * Return an array of the variables and their values.
e0736817 408 * @return array name => value.
06525476
TH
409 */
410 public function get_values() {
1da4060f 411 return $this->values;
06525476
TH
412 }
413
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) {
1da4060f
TH
421 return $this->calculate_raw($this->substitute_values_for_eval($expression));
422 }
423
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) {
3d9645ae 431 // This validation trick from http://php.net/manual/en/function.eval.php .
1da4060f 432 if (!@eval('return true; $result = ' . $expression . ';')) {
19e911a2
TH
433 throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression);
434 }
1da4060f 435 return eval('return ' . $expression . ';');
06525476
TH
436 }
437
438 /**
1da4060f 439 * Substitute variable placehodlers like {a} with their value wrapped in ().
06525476
TH
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 */
1da4060f
TH
445 protected function substitute_values_for_eval($expression) {
446 return str_replace($this->search, $this->safevalue, $expression);
447 }
448
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);
06525476
TH
459 }
460
1da4060f
TH
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 */
cdece95e 467 public function replace_expressions_in_text($text, $length = null, $format = null) {
29005a54 468 $vs = $this; // Can't use $this in a PHP closure.
cdece95e
TH
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);
1da4060f 473 return $this->substitute_values_pretty($text);
06525476 474 }
29005a54 475}