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