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