Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | 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
/**
19
 * Question type class for the numerical question type.
20
 *
21
 * @package    qtype
22
 * @subpackage numerical
23
 * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
24
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25
 */
26
 
27
 
28
defined('MOODLE_INTERNAL') || die();
29
 
30
require_once($CFG->libdir . '/questionlib.php');
31
require_once($CFG->dirroot . '/question/type/numerical/question.php');
32
 
33
 
34
/**
35
 * The numerical question type class.
36
 *
37
 * This class contains some special features in order to make the
38
 * question type embeddable within a multianswer (cloze) question
39
 *
40
 * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
41
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
42
 */
43
class qtype_numerical extends question_type {
44
    const UNITINPUT = 0;
45
    const UNITRADIO = 1;
46
    const UNITSELECT = 2;
47
 
48
    const UNITNONE = 3;
49
    const UNITGRADED = 1;
50
    const UNITOPTIONAL = 0;
51
 
52
    const UNITGRADEDOUTOFMARK = 1;
53
    const UNITGRADEDOUTOFMAX = 2;
54
 
55
    /**
56
     * Validate that a string is a number formatted correctly for the current locale.
57
     * @param string $x a string
58
     * @return bool whether $x is a number that the numerical question type can interpret.
59
     */
60
    public static function is_valid_number(string $x): bool {
61
        $ap = new qtype_numerical_answer_processor(array());
62
        list($value, $unit) = $ap->apply_units($x);
63
        return !is_null($value) && !$unit;
64
    }
65
 
66
    public function get_question_options($question) {
1441 ariadna 67
        global $DB;
1 efrain 68
        parent::get_question_options($question);
69
        // Get the question answers and their respective tolerances
70
        // Note: question_numerical is an extension of the answer table rather than
71
        //       the question table as is usually the case for qtype
72
        //       specific tables.
1441 ariadna 73
        // If the numerical record is missing for some reason (e.g. MDL-85721), use a default tolerance.
1 efrain 74
        if (!$question->options->answers = $DB->get_records_sql(
1441 ariadna 75
            "
76
                SELECT a.*, COALESCE(n.tolerance, '0') AS tolerance
77
                  FROM {question_answers} a
78
             LEFT JOIN {question_numerical} n ON a.id = n.answer
79
                 WHERE a.question = ?
80
              ORDER BY a.id ASC
81
            ",
82
            [$question->id],
83
        )) {
84
            debugging('Error: Missing question answer for numerical question ' .
1 efrain 85
                    $question->id . '!');
86
        }
87
 
88
        $question->hints = $DB->get_records('question_hints',
89
                array('questionid' => $question->id), 'id ASC');
90
 
91
        $this->get_numerical_units($question);
92
        // Get_numerical_options() need to know if there are units
93
        // to set correctly default values.
94
        $this->get_numerical_options($question);
95
 
96
        // If units are defined we strip off the default unit from the answer, if
97
        // it is present. (Required for compatibility with the old code and DB).
98
        if ($defaultunit = $this->get_default_numerical_unit($question)) {
99
            foreach ($question->options->answers as $key => $val) {
100
                $answer = trim($val->answer);
101
                $length = strlen($defaultunit->unit);
102
                if ($length && substr($answer, -$length) == $defaultunit->unit) {
103
                    $question->options->answers[$key]->answer =
104
                            substr($answer, 0, strlen($answer)-$length);
105
                }
106
            }
107
        }
108
 
109
        return true;
110
    }
111
 
112
    public function get_numerical_units(&$question) {
113
        global $DB;
114
 
115
        if ($units = $DB->get_records('question_numerical_units',
116
                array('question' => $question->id), 'id ASC')) {
117
            $units = array_values($units);
118
        } else {
119
            $units = array();
120
        }
121
        foreach ($units as $key => $unit) {
122
            $units[$key]->multiplier = clean_param($unit->multiplier, PARAM_FLOAT);
123
        }
124
        $question->options->units = $units;
125
        return true;
126
    }
127
 
128
    public function get_default_numerical_unit($question) {
129
        if (isset($question->options->units[0])) {
130
            foreach ($question->options->units as $unit) {
131
                if (abs($unit->multiplier - 1.0) < '1.0e-' . ini_get('precision')) {
132
                    return $unit;
133
                }
134
            }
135
        }
136
        return false;
137
    }
138
 
139
    public function get_numerical_options($question) {
140
        global $DB;
141
        if (!$options = $DB->get_record('question_numerical_options',
142
                array('question' => $question->id))) {
143
            // Old question, set defaults.
144
            $question->options->unitgradingtype = 0;
145
            $question->options->unitpenalty = 0.1;
146
            if ($defaultunit = $this->get_default_numerical_unit($question)) {
147
                $question->options->showunits = self::UNITINPUT;
148
            } else {
149
                $question->options->showunits = self::UNITNONE;
150
            }
151
            $question->options->unitsleft = 0;
152
 
153
        } else {
154
            $question->options->unitgradingtype = $options->unitgradingtype;
155
            $question->options->unitpenalty = $options->unitpenalty;
156
            $question->options->showunits = $options->showunits;
157
            $question->options->unitsleft = $options->unitsleft;
158
        }
159
 
160
        return true;
161
    }
162
 
163
    public function save_defaults_for_new_questions(stdClass $fromform): void {
164
        parent::save_defaults_for_new_questions($fromform);
165
        $this->set_default_value('unitrole', $fromform->unitrole);
166
        $this->set_default_value('unitpenalty', $fromform->unitpenalty);
167
        $this->set_default_value('unitgradingtypes', $fromform->unitgradingtypes);
168
        $this->set_default_value('multichoicedisplay', $fromform->multichoicedisplay);
169
        $this->set_default_value('unitsleft', $fromform->unitsleft);
170
    }
171
 
172
    /**
173
     * Save the units and the answers associated with this question.
174
     */
175
    public function save_question_options($question) {
176
        global $DB;
177
        $context = $question->context;
178
 
179
        // Get old versions of the objects.
180
        $oldanswers = $DB->get_records('question_answers',
181
                array('question' => $question->id), 'id ASC');
182
        $oldoptions = $DB->get_records('question_numerical',
183
                array('question' => $question->id), 'answer ASC');
184
 
185
        // Save the units.
186
        $result = $this->save_units($question);
187
        if (isset($result->error)) {
188
            return $result;
189
        } else {
190
            $units = $result->units;
191
        }
192
 
193
        // Insert all the new answers.
194
        foreach ($question->answer as $key => $answerdata) {
195
            // Check for, and ingore, completely blank answer from the form.
196
            if (trim($answerdata) == '' && $question->fraction[$key] == 0 &&
197
                    html_is_blank($question->feedback[$key]['text'])) {
198
                continue;
199
            }
200
 
201
            // Update an existing answer if possible.
202
            $answer = array_shift($oldanswers);
203
            if (!$answer) {
204
                $answer = new stdClass();
205
                $answer->question = $question->id;
206
                $answer->answer = '';
207
                $answer->feedback = '';
208
                $answer->id = $DB->insert_record('question_answers', $answer);
209
            }
210
 
211
            if (trim($answerdata) === '*') {
212
                $answer->answer = '*';
213
            } else {
214
                $answer->answer = $this->apply_unit($answerdata, $units,
215
                        !empty($question->unitsleft));
216
                if ($answer->answer === false) {
217
                    $result->notice = get_string('invalidnumericanswer', 'qtype_numerical');
218
                }
219
            }
220
            $answer->fraction = $question->fraction[$key];
221
            $answer->feedback = $this->import_or_save_files($question->feedback[$key],
222
                    $context, 'question', 'answerfeedback', $answer->id);
223
            $answer->feedbackformat = $question->feedback[$key]['format'];
224
            $DB->update_record('question_answers', $answer);
225
 
226
            // Set up the options object.
227
            if (!$options = array_shift($oldoptions)) {
228
                $options = new stdClass();
229
            }
230
            $options->question = $question->id;
231
            $options->answer   = $answer->id;
232
            if (trim($question->tolerance[$key]) == '') {
233
                $options->tolerance = '';
234
            } else {
235
                $options->tolerance = $this->apply_unit($question->tolerance[$key],
236
                        $units, !empty($question->unitsleft));
237
                if ($options->tolerance === false) {
238
                    $result->notice = get_string('invalidnumerictolerance', 'qtype_numerical');
239
                }
240
                $options->tolerance = (string)$options->tolerance;
241
            }
242
            if (isset($options->id)) {
243
                $DB->update_record('question_numerical', $options);
244
            } else {
245
                $DB->insert_record('question_numerical', $options);
246
            }
247
        }
248
 
249
        // Delete any left over old answer records.
250
        $fs = get_file_storage();
251
        foreach ($oldanswers as $oldanswer) {
252
            $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id);
253
            $DB->delete_records('question_answers', array('id' => $oldanswer->id));
254
        }
255
        foreach ($oldoptions as $oldoption) {
256
            $DB->delete_records('question_numerical', array('id' => $oldoption->id));
257
        }
258
 
259
        $result = $this->save_unit_options($question);
260
        if (!empty($result->error) || !empty($result->notice)) {
261
            return $result;
262
        }
263
 
264
        $this->save_hints($question);
265
 
266
        return true;
267
    }
268
 
269
    /**
270
     * The numerical options control the display and the grading of the unit
271
     * part of the numerical question and related types (calculateds)
272
     * Questions previous to 2.0 do not have this table as multianswer questions
273
     * in all versions including 2.0. The default values are set to give the same grade
274
     * as old question.
275
     *
276
     */
277
    public function save_unit_options($question) {
278
        global $DB;
279
        $result = new stdClass();
280
 
281
        $update = true;
282
        $options = $DB->get_record('question_numerical_options',
283
                array('question' => $question->id));
284
        if (!$options) {
285
            $options = new stdClass();
286
            $options->question = $question->id;
287
            $options->id = $DB->insert_record('question_numerical_options', $options);
288
        }
289
 
290
        if (isset($question->unitpenalty)) {
291
            $options->unitpenalty = $question->unitpenalty;
292
        } else {
293
            // Either an old question or a close question type.
294
            $options->unitpenalty = 1;
295
        }
296
 
297
        $options->unitgradingtype = 0;
298
        if (isset($question->unitrole)) {
299
            // Saving the editing form.
300
            $options->showunits = $question->unitrole;
301
            if ($question->unitrole == self::UNITGRADED) {
302
                $options->unitgradingtype = $question->unitgradingtypes;
303
                $options->showunits = $question->multichoicedisplay;
304
            }
305
 
306
        } else if (isset($question->showunits)) {
307
            // Updated import, e.g. Moodle XML.
308
            $options->showunits = $question->showunits;
309
            if (isset($question->unitgradingtype)) {
310
                $options->unitgradingtype = $question->unitgradingtype;
311
            }
312
        } else {
313
            // Legacy import.
314
            if ($defaultunit = $this->get_default_numerical_unit($question)) {
315
                $options->showunits = self::UNITINPUT;
316
            } else {
317
                $options->showunits = self::UNITNONE;
318
            }
319
        }
320
 
321
        $options->unitsleft = !empty($question->unitsleft);
322
 
323
        $DB->update_record('question_numerical_options', $options);
324
 
325
        // Report any problems.
326
        if (!empty($result->notice)) {
327
            return $result;
328
        }
329
 
330
        return true;
331
    }
332
 
333
    public function save_units($question) {
334
        global $DB;
335
        $result = new stdClass();
336
 
337
        // Delete the units previously saved for this question.
338
        $DB->delete_records('question_numerical_units', array('question' => $question->id));
339
 
340
        // Nothing to do.
341
        if (!isset($question->multiplier)) {
342
            $result->units = array();
343
            return $result;
344
        }
345
 
346
        // Save the new units.
347
        $units = array();
348
        $unitalreadyinsert = array();
349
        foreach ($question->multiplier as $i => $multiplier) {
350
            // Discard any unit which doesn't specify the unit or the multiplier.
351
            if (!empty($question->multiplier[$i]) && !empty($question->unit[$i]) &&
352
                    !array_key_exists($question->unit[$i], $unitalreadyinsert)) {
353
                $unitalreadyinsert[$question->unit[$i]] = 1;
354
                $units[$i] = new stdClass();
355
                $units[$i]->question = $question->id;
356
                $units[$i]->multiplier = $this->apply_unit($question->multiplier[$i],
357
                        array(), false);
358
                $units[$i]->unit = $question->unit[$i];
359
                $DB->insert_record('question_numerical_units', $units[$i]);
360
            }
361
        }
362
        unset($question->multiplier, $question->unit);
363
 
364
        $result->units = &$units;
365
        return $result;
366
    }
367
 
368
    protected function initialise_question_instance(question_definition $question, $questiondata) {
369
        parent::initialise_question_instance($question, $questiondata);
370
        $this->initialise_numerical_answers($question, $questiondata);
371
        $question->unitdisplay = $questiondata->options->showunits;
372
        $question->unitgradingtype = $questiondata->options->unitgradingtype;
373
        $question->unitpenalty = $questiondata->options->unitpenalty;
374
        $question->unitsleft = $questiondata->options->unitsleft;
375
        $question->ap = $this->make_answer_processor($questiondata->options->units,
376
                $questiondata->options->unitsleft);
377
    }
378
 
379
    public function initialise_numerical_answers(question_definition $question, $questiondata) {
380
        $question->answers = array();
381
        if (empty($questiondata->options->answers)) {
382
            return;
383
        }
384
        foreach ($questiondata->options->answers as $a) {
385
            $question->answers[$a->id] = new qtype_numerical_answer($a->id, $a->answer,
386
                    $a->fraction, $a->feedback, $a->feedbackformat, $a->tolerance);
387
        }
388
    }
389
 
390
    public function make_answer_processor($units, $unitsleft) {
391
        if (empty($units)) {
392
            return new qtype_numerical_answer_processor(array());
393
        }
394
 
395
        $cleanedunits = array();
396
        foreach ($units as $unit) {
397
            $cleanedunits[$unit->unit] = $unit->multiplier;
398
        }
399
 
400
        return new qtype_numerical_answer_processor($cleanedunits, $unitsleft);
401
    }
402
 
403
    public function delete_question($questionid, $contextid) {
404
        global $DB;
405
        $DB->delete_records('question_numerical', array('question' => $questionid));
406
        $DB->delete_records('question_numerical_options', array('question' => $questionid));
407
        $DB->delete_records('question_numerical_units', array('question' => $questionid));
408
 
409
        parent::delete_question($questionid, $contextid);
410
    }
411
 
412
    public function get_random_guess_score($questiondata) {
413
        foreach ($questiondata->options->answers as $aid => $answer) {
414
            if ('*' == trim($answer->answer)) {
415
                return max($answer->fraction - $questiondata->options->unitpenalty, 0);
416
            }
417
        }
418
        return 0;
419
    }
420
 
421
    /**
422
     * Add a unit to a response for display.
423
     * @param object $questiondata the data defining the quetsion.
424
     * @param string $answer a response.
425
     * @param object $unit a unit. If null, {@link get_default_numerical_unit()}
426
     * is used.
427
     */
428
    public function add_unit($questiondata, $answer, $unit = null) {
429
        if (is_null($unit)) {
430
            $unit = $this->get_default_numerical_unit($questiondata);
431
        }
432
 
433
        if (!$unit) {
434
            return $answer;
435
        }
436
 
437
        if (!empty($questiondata->options->unitsleft)) {
438
            return $unit->unit . ' ' . $answer;
439
        } else {
440
            return $answer . ' ' . $unit->unit;
441
        }
442
    }
443
 
444
    public function get_possible_responses($questiondata) {
445
        $responses = array();
446
 
447
        $unit = $this->get_default_numerical_unit($questiondata);
448
 
449
        $starfound = false;
450
        foreach ($questiondata->options->answers as $aid => $answer) {
451
            $responseclass = $answer->answer;
452
 
453
            if ($responseclass === '*') {
454
                $starfound = true;
455
            } else {
456
                $responseclass = $this->add_unit($questiondata, $responseclass, $unit);
457
 
458
                $ans = new qtype_numerical_answer($answer->id, $answer->answer, $answer->fraction,
459
                        $answer->feedback, $answer->feedbackformat, $answer->tolerance);
460
                list($min, $max) = $ans->get_tolerance_interval();
461
                $responseclass .= " ({$min}..{$max})";
462
            }
463
 
464
            $responses[$aid] = new question_possible_response($responseclass,
465
                    $answer->fraction);
466
        }
467
 
468
        if (!$starfound) {
469
            $responses[0] = new question_possible_response(
470
                    get_string('didnotmatchanyanswer', 'question'), 0);
471
        }
472
 
473
        $responses[null] = question_possible_response::no_response();
474
 
475
        return array($questiondata->id => $responses);
476
    }
477
 
478
    /**
479
     * Checks if the $rawresponse has a unit and applys it if appropriate.
480
     *
481
     * @param string $rawresponse  The response string to be converted to a float.
482
     * @param array $units         An array with the defined units, where the
483
     *                             unit is the key and the multiplier the value.
484
     * @return float               The rawresponse with the unit taken into
485
     *                             account as a float.
486
     */
487
    public function apply_unit($rawresponse, $units, $unitsleft) {
488
        $ap = $this->make_answer_processor($units, $unitsleft);
489
        list($value, $unit, $multiplier) = $ap->apply_units($rawresponse);
490
        if (!is_null($multiplier)) {
491
            $value *= $multiplier;
492
        }
493
        return $value;
494
    }
495
 
496
    public function move_files($questionid, $oldcontextid, $newcontextid) {
497
        $fs = get_file_storage();
498
 
499
        parent::move_files($questionid, $oldcontextid, $newcontextid);
500
        $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid);
501
        $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
502
    }
503
 
504
    protected function delete_files($questionid, $contextid) {
505
        $fs = get_file_storage();
506
 
507
        parent::delete_files($questionid, $contextid);
508
        $this->delete_files_in_answers($questionid, $contextid);
509
        $this->delete_files_in_hints($questionid, $contextid);
510
    }
511
}
512
 
513
 
514
/**
515
 * This class processes numbers with units.
516
 *
517
 * @copyright 2010 The Open University
518
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
519
 */
520
class qtype_numerical_answer_processor {
521
    /** @var array unit name => multiplier. */
522
    protected $units;
523
    /** @var string character used as decimal point. */
524
    protected $decsep;
525
    /** @var string character used as thousands separator. */
526
    protected $thousandssep;
527
    /** @var boolean whether the units come before or after the number. */
528
    protected $unitsbefore;
529
 
530
    protected $regex = null;
531
 
532
    public function __construct($units, $unitsbefore = false, $decsep = null,
533
            $thousandssep = null) {
534
        if (is_null($decsep)) {
535
            $decsep = get_string('decsep', 'langconfig');
536
        }
537
        $this->decsep = $decsep;
538
 
539
        if (is_null($thousandssep)) {
540
            $thousandssep = get_string('thousandssep', 'langconfig');
541
        }
542
        $this->thousandssep = $thousandssep;
543
 
544
        $this->units = $units;
545
        $this->unitsbefore = $unitsbefore;
546
    }
547
 
548
    /**
549
     * Set the decimal point and thousands separator character that should be used.
550
     * @param string $decsep
551
     * @param string $thousandssep
552
     */
553
    public function set_characters($decsep, $thousandssep) {
554
        $this->decsep = $decsep;
555
        $this->thousandssep = $thousandssep;
556
        $this->regex = null;
557
    }
558
 
559
    /** @return string the decimal point character used. */
560
    public function get_point() {
561
        return $this->decsep;
562
    }
563
 
564
    /** @return string the thousands separator character used. */
565
    public function get_separator() {
566
        return $this->thousandssep;
567
    }
568
 
569
    /**
570
     * @return bool If the student's response contains a '.' or a ',' that
571
     * matches the thousands separator in the current locale. In this case, the
572
     * parsing in apply_unit can give a result that the student did not expect.
573
     */
574
    public function contains_thousands_seaparator($value) {
575
        if (!in_array($this->thousandssep, array('.', ','))) {
576
            return false;
577
        }
578
 
579
        return strpos($value, $this->thousandssep) !== false;
580
    }
581
 
582
    /**
583
     * Create the regular expression that {@link parse_response()} requires.
584
     * @return string
585
     */
586
    protected function build_regex() {
587
        if (!is_null($this->regex)) {
588
            return $this->regex;
589
        }
590
 
591
        $decsep = preg_quote($this->decsep, '/');
592
        $thousandssep = preg_quote($this->thousandssep, '/');
593
        $beforepointre = '([+-]?[' . $thousandssep . '\d]*)';
594
        $decimalsre = $decsep . '(\d*)';
595
        $exponentre = '(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)';
596
 
597
        $numberbit = "{$beforepointre}(?:{$decimalsre})?(?:{$exponentre})?";
598
 
599
        if ($this->unitsbefore) {
600
            $this->regex = "/{$numberbit}$/";
601
        } else {
602
            $this->regex = "/^{$numberbit}/";
603
        }
604
        return $this->regex;
605
    }
606
 
607
    /**
608
     * This method can be used for more locale-strict parsing of repsonses. At the
609
     * moment we don't use it, and instead use the more lax parsing in apply_units.
610
     * This is just a note that this funciton was used in the past, so if you are
611
     * intersted, look through version control history.
612
     *
613
     * Take a string which is a number with or without a decimal point and exponent,
614
     * and possibly followed by one of the units, and split it into bits.
615
     * @param string $response a value, optionally with a unit.
616
     * @return array four strings (some of which may be blank) the digits before
617
     * and after the decimal point, the exponent, and the unit. All four will be
618
     * null if the response cannot be parsed.
619
     */
620
    protected function parse_response($response) {
621
        if (!preg_match($this->build_regex(), $response, $matches)) {
622
            return array(null, null, null, null);
623
        }
624
 
625
        $matches += array('', '', '', ''); // Fill in any missing matches.
626
        list($matchedpart, $beforepoint, $decimals, $exponent) = $matches;
627
 
628
        // Strip out thousands separators.
629
        $beforepoint = str_replace($this->thousandssep, '', $beforepoint);
630
 
631
        // Must be either something before, or something after the decimal point.
632
        // (The only way to do this in the regex would make it much more complicated.)
633
        if ($beforepoint === '' && $decimals === '') {
634
            return array(null, null, null, null);
635
        }
636
 
637
        if ($this->unitsbefore) {
638
            $unit = substr($response, 0, -strlen($matchedpart));
639
        } else {
640
            $unit = substr($response, strlen($matchedpart));
641
        }
642
        $unit = trim($unit);
643
 
644
        return array($beforepoint, $decimals, $exponent, $unit);
645
    }
646
 
647
    /**
648
     * Takes a number in almost any localised form, and possibly with a unit
649
     * after it. It separates off the unit, if present, and converts to the
650
     * default unit, by using the given unit multiplier.
651
     *
652
     * @param string $response a value, optionally with a unit.
653
     * @return array(numeric, string, multiplier) the value with the unit stripped, and normalised
654
     *      by the unit multiplier, if any, and the unit string, for reference.
655
     */
656
    public function apply_units($response, $separateunit = null): array {
657
        if ($response === null || trim($response) === '') {
658
            return [null, null, null];
659
        }
660
 
661
        // Strip spaces (which may be thousands separators) and change other forms
662
        // of writing e to e.
663
        $response = str_replace(' ', '', $response);
664
        $response = preg_replace('~(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)~', 'e$1', $response);
665
 
666
        // If a . is present or there are multiple , (i.e. 2,456,789 ) assume ,
667
        // is a thouseands separator, and strip it, else assume it is a decimal
668
        // separator, and change it to ..
669
        if (strpos($response, '.') !== false || substr_count($response, ',') > 1) {
670
            $response = str_replace(',', '', $response);
671
        } else {
672
            $response = str_replace([$this->thousandssep, $this->decsep, ','], ['', '.', '.'], $response);
673
        }
674
 
675
        $regex = '[+-]?(?:\d+(?:\\.\d*)?|\\.\d+)(?:e[-+]?\d+)?';
676
        if ($this->unitsbefore) {
677
            $regex = "/{$regex}$/";
678
        } else {
679
            $regex = "/^{$regex}/";
680
        }
681
        if (!preg_match($regex, $response, $matches)) {
682
            return array(null, null, null);
683
        }
684
 
685
        $numberstring = $matches[0];
686
        if ($this->unitsbefore) {
687
            // Substr returns false when it means '', so cast back to string.
688
            $unit = (string) substr($response, 0, -strlen($numberstring));
689
        } else {
690
            $unit = (string) substr($response, strlen($numberstring));
691
        }
692
 
693
        if (!is_null($separateunit)) {
694
            $unit = $separateunit;
695
        }
696
 
697
        if ($this->is_known_unit($unit)) {
698
            $multiplier = 1 / $this->units[$unit];
699
        } else {
700
            $multiplier = null;
701
        }
702
 
703
        return array($numberstring + 0, $unit, $multiplier); // The + 0 is to convert to number.
704
    }
705
 
706
    /**
707
     * @return string the default unit.
708
     */
709
    public function get_default_unit() {
710
        reset($this->units);
711
        return key($this->units);
712
    }
713
 
714
    /**
715
     * @param string $answer a response.
716
     * @param string $unit a unit.
717
     */
718
    public function add_unit($answer, $unit = null) {
719
        if (is_null($unit)) {
720
            $unit = $this->get_default_unit();
721
        }
722
 
723
        if (!$unit) {
724
            return $answer;
725
        }
726
 
727
        if ($this->unitsbefore) {
728
            return $unit . ' ' . $answer;
729
        } else {
730
            return $answer . ' ' . $unit;
731
        }
732
    }
733
 
734
    /**
735
     * Is this unit recognised.
736
     * @param string $unit the unit
737
     * @return bool whether this is a unit we recognise.
738
     */
739
    public function is_known_unit($unit) {
740
        return array_key_exists($unit, $this->units);
741
    }
742
 
743
    /**
744
     * Whether the units go before or after the number.
745
     * @return true = before, false = after.
746
     */
747
    public function are_units_before() {
748
        return $this->unitsbefore;
749
    }
750
 
751
    /**
752
     * Get the units as an array suitably for passing to html_writer::select.
753
     * @return array of unit choices.
754
     */
755
    public function get_unit_options() {
756
        $options = array();
757
        foreach ($this->units as $unit => $notused) {
758
            $options[$unit] = $unit;
759
        }
760
        return $options;
761
    }
762
}