Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
 * Numerical question definition class.
19
 *
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
24
 */
25
 
26
 
27
defined('MOODLE_INTERNAL') || die();
28
 
29
require_once($CFG->dirroot . '/question/type/questionbase.php');
30
 
31
/**
32
 * Represents a numerical question.
33
 *
34
 * @copyright  2009 The Open University
35
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36
 */
37
class qtype_numerical_question extends question_graded_automatically {
38
    /** @var array of question_answer. */
39
    public $answers = array();
40
 
41
    /** @var int one of the constants UNITNONE, UNITRADIO, UNITSELECT or UNITINPUT. */
42
    public $unitdisplay;
43
    /** @var int one of the constants UNITGRADEDOUTOFMARK or UNITGRADEDOUTOFMAX. */
44
    public $unitgradingtype;
45
    /** @var number the penalty for a missing or unrecognised unit. */
46
    public $unitpenalty;
47
    /** @var boolean whether the units come before or after the number */
48
    public $unitsleft;
49
    /** @var qtype_numerical_answer_processor */
50
    public $ap;
51
 
52
    public function get_expected_data() {
53
        $expected = array('answer' => PARAM_RAW_TRIMMED);
54
        if ($this->has_separate_unit_field()) {
55
            $expected['unit'] = PARAM_RAW_TRIMMED;
56
        }
57
        return $expected;
58
    }
59
 
60
    public function has_separate_unit_field() {
61
        return $this->unitdisplay == qtype_numerical::UNITRADIO ||
62
                $this->unitdisplay == qtype_numerical::UNITSELECT;
63
    }
64
 
65
    public function start_attempt(question_attempt_step $step, $variant) {
66
        $step->set_qt_var('_separators',
67
                $this->ap->get_point() . '$' . $this->ap->get_separator());
68
    }
69
 
70
    public function apply_attempt_state(question_attempt_step $step) {
71
        list($point, $separator) = explode('$', $step->get_qt_var('_separators'));
72
                $this->ap->set_characters($point, $separator);
73
    }
74
 
75
    public function summarise_response(array $response) {
76
        if (isset($response['answer'])) {
77
            $resp = $response['answer'];
78
        } else {
79
            $resp = null;
80
        }
81
 
82
        if ($this->has_separate_unit_field() && !empty($response['unit'])) {
83
            $resp = $this->ap->add_unit($resp, $response['unit']);
84
        }
85
 
86
        return $resp;
87
    }
88
 
89
    public function un_summarise_response(string $summary) {
90
        if ($this->has_separate_unit_field()) {
91
            throw new coding_exception('Sorry, but at the moment un_summarise_response cannot handle the
92
                has_separate_unit_field case for numerical questions.
93
                    If you need this, you will have to implement it yourself.');
94
        }
95
 
96
        if (!empty($summary)) {
97
            return ['answer' => $summary];
98
        } else {
99
            return [];
100
        }
101
    }
102
 
103
    public function is_gradable_response(array $response) {
104
        return array_key_exists('answer', $response) &&
105
                ($response['answer'] || $response['answer'] === '0' || $response['answer'] === 0);
106
    }
107
 
108
    public function is_complete_response(array $response) {
109
        if (!$this->is_gradable_response($response)) {
110
            return false;
111
        }
112
 
113
        list($value, $unit) = $this->ap->apply_units($response['answer']);
114
        if (is_null($value)) {
115
            return false;
116
        }
117
 
118
        if ($this->unitdisplay != qtype_numerical::UNITINPUT && $unit) {
119
            return false;
120
        }
121
 
122
        if ($this->has_separate_unit_field() && empty($response['unit'])) {
123
            return false;
124
        }
125
 
126
        if ($this->ap->contains_thousands_seaparator($response['answer'])) {
127
            return false;
128
        }
129
 
130
        return true;
131
    }
132
 
133
    public function get_validation_error(array $response) {
134
        if (!$this->is_gradable_response($response)) {
135
            return get_string('pleaseenterananswer', 'qtype_numerical');
136
        }
137
 
138
        list($value, $unit) = $this->ap->apply_units($response['answer']);
139
        if (is_null($value)) {
140
            return get_string('invalidnumber', 'qtype_numerical');
141
        }
142
 
143
        if ($this->unitdisplay != qtype_numerical::UNITINPUT && $unit) {
144
            return get_string('invalidnumbernounit', 'qtype_numerical');
145
        }
146
 
147
        if ($this->has_separate_unit_field() && empty($response['unit'])) {
148
            return get_string('unitnotselected', 'qtype_numerical');
149
        }
150
 
151
        if ($this->ap->contains_thousands_seaparator($response['answer'])) {
152
            return get_string('pleaseenteranswerwithoutthousandssep', 'qtype_numerical',
153
                    $this->ap->get_separator());
154
        }
155
 
156
        return '';
157
    }
158
 
159
    public function is_same_response(array $prevresponse, array $newresponse) {
160
        if (!question_utils::arrays_same_at_key_missing_is_blank(
161
                $prevresponse, $newresponse, 'answer')) {
162
            return false;
163
        }
164
 
165
        if ($this->has_separate_unit_field()) {
166
            return question_utils::arrays_same_at_key_missing_is_blank(
167
                $prevresponse, $newresponse, 'unit');
168
        }
169
 
170
        return true;
171
    }
172
 
173
    public function get_correct_response() {
174
        $answer = $this->get_correct_answer();
175
        if (!$answer) {
176
            return array();
177
        }
178
 
179
        $response = array('answer' => str_replace('.', $this->ap->get_point(), $answer->answer));
180
 
181
        if ($this->has_separate_unit_field()) {
182
            $response['unit'] = $this->ap->get_default_unit();
183
        } else if ($this->unitdisplay == qtype_numerical::UNITINPUT) {
184
            $response['answer'] = $this->ap->add_unit($answer->answer);
185
        }
186
 
187
        return $response;
188
    }
189
 
190
    /**
191
     * Get an answer that contains the feedback and fraction that should be
192
     * awarded for this response.
193
     * @param number $value the numerical value of a response.
194
     * @param number $multiplier for the unit the student gave, if any. When no
195
     *      unit was given, or an unrecognised unit was given, $multiplier will be null.
196
     * @return question_answer the matching answer.
197
     */
198
    public function get_matching_answer($value, $multiplier) {
199
        if (is_null($value) || $value === '') {
200
            return null;
201
        }
202
 
203
        if (!is_null($multiplier)) {
204
            $scaledvalue = $value * $multiplier;
205
        } else {
206
            $scaledvalue = $value;
207
        }
208
        foreach ($this->answers as $answer) {
209
            if ($answer->within_tolerance($scaledvalue)) {
210
                return $answer;
211
            } else if ($answer->within_tolerance($value)) {
212
                return $answer;
213
            }
214
        }
215
 
216
        return null;
217
    }
218
 
219
    /**
220
     * Checks if the provided $multiplier is appropriate for the unit of the given $value,
221
     * ensuring that multiplying $value by the $multiplier yields the expected $answer.
222
     *
223
     * @param qtype_numerical_answer $answer The expected result when multiplying $value by the appropriate $multiplier.
224
     * @param float $value The provided value
225
     * @param float|null $multiplier The multiplier value for the unit of $value.
226
     * @return bool Returns true if the $multiplier is correct for the unit of $value, false otherwise.
227
     */
228
    public function is_unit_right(qtype_numerical_answer $answer, float $value, ?float $multiplier): bool {
229
        if (is_null($multiplier)) {
230
            return false;
231
        }
232
 
233
        return $answer->within_tolerance($multiplier * $value);
234
    }
235
 
236
    public function get_correct_answer() {
237
        foreach ($this->answers as $answer) {
238
            $state = question_state::graded_state_for_fraction($answer->fraction);
239
            if ($state == question_state::$gradedright) {
240
                return $answer;
241
            }
242
        }
243
        return null;
244
    }
245
 
246
    /**
247
     * Adjust the fraction based on whether the unit was correct.
248
     * @param number $fraction
249
     * @param bool $unitisright
250
     * @return number
251
     */
252
    public function apply_unit_penalty($fraction, $unitisright) {
253
        if ($unitisright) {
254
            return $fraction;
255
        }
256
 
257
        if ($this->unitgradingtype == qtype_numerical::UNITGRADEDOUTOFMARK) {
258
            $fraction -= $this->unitpenalty * $fraction;
259
        } else if ($this->unitgradingtype == qtype_numerical::UNITGRADEDOUTOFMAX) {
260
            $fraction -= $this->unitpenalty;
261
        }
262
        return max($fraction, 0);
263
    }
264
 
265
    public function grade_response(array $response) {
266
        if ($this->has_separate_unit_field()) {
267
            $selectedunit = $response['unit'];
268
        } else {
269
            $selectedunit = null;
270
        }
271
        list($value, $unit, $multiplier) = $this->ap->apply_units(
272
                $response['answer'], $selectedunit);
273
 
274
        /** @var qtype_numerical_answer $answer */
275
        $answer = $this->get_matching_answer($value, $multiplier);
276
        if (!$answer) {
277
            return array(0, question_state::$gradedwrong);
278
        }
279
 
280
        $unitisright = $this->is_unit_right($answer, $value, $multiplier);
281
        $fraction = $this->apply_unit_penalty($answer->fraction, $unitisright);
282
        return array($fraction, question_state::graded_state_for_fraction($fraction));
283
    }
284
 
285
    public function classify_response(array $response) {
286
        if (!$this->is_gradable_response($response)) {
287
            return array($this->id => question_classified_response::no_response());
288
        }
289
 
290
        if ($this->has_separate_unit_field()) {
291
            $selectedunit = $response['unit'];
292
        } else {
293
            $selectedunit = null;
294
        }
295
        list($value, $unit, $multiplier) = $this->ap->apply_units($response['answer'], $selectedunit);
296
        /** @var qtype_numerical_answer $ans */
297
        $ans = $this->get_matching_answer($value, $multiplier);
298
 
299
        $resp = $response['answer'];
300
        if ($this->has_separate_unit_field()) {
301
            $resp = $this->ap->add_unit($resp, $unit);
302
        }
303
 
304
        if ($value === null) {
305
            // Invalid response shown as no response (but show actual response).
306
            return array($this->id => new question_classified_response(null, $resp, 0));
307
        } else if (!$ans) {
308
            // Does not match any answer.
309
            return array($this->id => new question_classified_response(0, $resp, 0));
310
        }
311
 
312
        $unitisright = $this->is_unit_right($ans, $value, $multiplier);
313
        return [
314
            $this->id => new question_classified_response($ans->id, $resp, $this->apply_unit_penalty($ans->fraction, $unitisright))
315
        ];
316
    }
317
 
318
    public function check_file_access($qa, $options, $component, $filearea, $args,
319
            $forcedownload) {
320
        if ($component == 'question' && $filearea == 'answerfeedback') {
321
            $currentanswer = $qa->get_last_qt_var('answer');
322
            if ($this->has_separate_unit_field()) {
323
                $selectedunit = $qa->get_last_qt_var('unit');
324
            } else {
325
                $selectedunit = null;
326
            }
327
            list($value, $unit, $multiplier) = $this->ap->apply_units(
328
                    $currentanswer, $selectedunit);
329
            $answer = $this->get_matching_answer($value, $multiplier);
330
            $answerid = reset($args); // Itemid is answer id.
331
            return $options->feedback && $answer && $answerid == $answer->id;
332
 
333
        } else if ($component == 'question' && $filearea == 'hint') {
334
            return $this->check_hint_file_access($qa, $options, $args);
335
 
336
        } else {
337
            return parent::check_file_access($qa, $options, $component, $filearea,
338
                    $args, $forcedownload);
339
        }
340
    }
341
 
342
    /**
343
     * Return the question settings that define this question as structured data.
344
     *
345
     * @param question_attempt $qa the current attempt for which we are exporting the settings.
346
     * @param question_display_options $options the question display options which say which aspects of the question
347
     * should be visible.
348
     * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
349
     */
350
    public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
351
        // This is a partial implementation, returning only the most relevant question settings for now,
352
        // ideally, we should return as much as settings as possible (depending on the state and display options).
353
 
354
        return [
355
            'unitgradingtype' => $this->unitgradingtype,
356
            'unitpenalty' => $this->unitpenalty,
357
            'unitdisplay' => $this->unitdisplay,
358
            'unitsleft' => $this->unitsleft,
359
        ];
360
    }
361
}
362
 
363
 
364
/**
365
 * Subclass of {@link question_answer} with the extra information required by
366
 * the numerical question type.
367
 *
368
 * @copyright  2009 The Open University
369
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
370
 */
371
class qtype_numerical_answer extends question_answer {
372
    /** @var float allowable margin of error. */
373
    public $tolerance;
374
    /** @var integer|string see {@link get_tolerance_interval()} for the meaning of this value. */
375
    public $tolerancetype = 2;
376
 
377
    public function __construct($id, $answer, $fraction, $feedback, $feedbackformat, $tolerance) {
378
        parent::__construct($id, $answer, $fraction, $feedback, $feedbackformat);
379
        $this->tolerance = abs((float)$tolerance);
380
    }
381
 
382
    public function get_tolerance_interval() {
383
        if ($this->answer === '*') {
384
            throw new coding_exception('Cannot work out tolerance interval for answer *.');
385
        }
386
 
387
        // Smallest number that, when added to 1, is different from 1.
388
        $epsilon = pow(10, -1 * ini_get('precision'));
389
 
390
        // We need to add a tiny fraction depending on the set precision to make
391
        // the comparison work correctly, otherwise seemingly equal values can
392
        // yield false. See MDL-3225.
393
        $tolerance = abs($this->tolerance) + $epsilon;
394
 
395
        switch ($this->tolerancetype) {
396
            case 1: case 'relative':
397
                $range = abs($this->answer) * $tolerance;
398
                return array($this->answer - $range, $this->answer + $range);
399
 
400
            case 2: case 'nominal':
401
                $tolerance = $this->tolerance + $epsilon * max(abs($this->tolerance), abs($this->answer), $epsilon);
402
                return array($this->answer - $tolerance, $this->answer + $tolerance);
403
 
404
            case 3: case 'geometric':
405
                $quotient = 1 + abs($tolerance);
406
                if ($this->answer < 0) {
407
                    return array($this->answer * $quotient, $this->answer / $quotient);
408
                }
409
                return array($this->answer / $quotient, $this->answer * $quotient);
410
 
411
            default:
412
                throw new coding_exception('Unknown tolerance type ' . $this->tolerancetype);
413
        }
414
    }
415
 
416
    public function within_tolerance($value) {
417
        if ($this->answer === '*') {
418
            return true;
419
        }
420
        list($min, $max) = $this->get_tolerance_interval();
421
        return $min <= $value && $value <= $max;
422
    }
423
}