• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

FormulasQuestion / moodle-qtype_formulas / 18244125068

04 Oct 2025 12:11PM UTC coverage: 97.558%. Remained the same
18244125068

Pull #286

github

web-flow
Merge 7331929a8 into 809f0856b
Pull Request #286: Update CI for Moodle 5.1

4355 of 4464 relevant lines covered (97.56%)

2065.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.09
/answer_unit.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/>.
16

17
namespace qtype_formulas;
18
use Exception;
19

20
/**
21
 * @copyright Copyright (c) 2010-2011, Hon Wai, Lau. All rights reserved.
22
 * @author Hon Wai, Lau <lau65536@gmail.com>
23
 * @package qtype_formulas
24
 * @license New and Simplified BSD licenses, http://www.opensource.org/licenses/bsd-license.php
25
 */
26

27

28
/**
29
 * This class provides methods to check whether an input unit is convertible to a unit in a list.
30
 *
31
 * A unit is a combination of the 'base units' and its exponents. For the International System of Units
32
 * (SI), there are 7 base units and some derived units. In comparison, the 'base units' here represents
33
 * the unit that is not 'compound units', i.e. 'base units' is a string without space.
34
 * In order to compare whether two string represent the same unit, the method employed here is to
35
 * decompose both string into 'base units' and exponents and then compare one by one.
36
 *
37
 * In addition, different units can represent the same dimension linked by a conversion factor.
38
 * All those units are acceptable, so there is a need for a conversion method. To solve this problem,
39
 * for the same dimensional quantity, user can specify conversion rules between several 'base units'.
40
 * Also, user are allow to specify one (and only one) conversion rule between different 'compound units'
41
 * known as the $target variable in the check_convertibility().
42
 *
43
 * Example format of rules, for 'compound unit': "J = N m = kg m^2/s^2, W = J/s = V A, Pa = N m^(-2)"
44
 * For 'base unit': "1 m = 1e-3 km = 100 cm; 1 cm = 0.3937 inch; 1024 B = 1 KiB; 1024 KiB = 1 MiB"
45
 * The scale of a unit without a prefix is assumed to be 1. For convenience of using SI prefix, an
46
 * alternative rules format for 'base unit' is that a string with a unit and colon, then followed by
47
 * a list of SI prefix separated by a space, e.g. "W: M k m" equal to "W = 1e3 mW = 1e-3kW = 1e-6MW"
48
 */
49
class answer_unit_conversion {
50
    private $mapping;           // Mapping of the unit to the (dimension class, scale).
51
    private $additional_rules;  // Additional rules other than the default rules.
52
    private $default_mapping;   // Default mapping of a user selected rules, usually Common SI prefix.
53
    private $default_last_id;   // Dimension class id counter.
54
    private $default_id;        // Id of the default rule.
55
    private $default_rules;     // String of the default rule in a particular format.
56
    // @codingStandardsIgnoreLine
57
    public static $unit_exclude_symbols = '][)(}{><0-9.,:;`~!@#^&*\/?|_=+ -';
58
    public static $prefix_scale_factors = array('d' => 1e-1, 'c' => 1e-2, 'da' => 1e1, 'h' => 1e2,
59
        'm' => 1e-3, 'u' => 1e-6, 'n' => 1e-9, 'p' => 1e-12, 'f' => 1e-15, 'a' => 1e-18, 'z' => 1e-21, 'y' => 1e-24,
60
        'k' => 1e3,  'M' => 1e6,  'G' => 1e9,  'T' => 1e12,  'P' => 1e15,  'E' => 1e18,  'Z' => 1e21,  'Y' => 1e24);
61
    // For convenience, u is used for micro-, rather than "mu", which has multiple similar UTF representations.
62

63
    // Initialize the internal conversion rule to empty. No exception raised.
64
    public function __construct() {
65
        $this->default_id = 0;
936✔
66
        $this->default_rules = '';
936✔
67
        $this->default_mapping = null;
936✔
68
        $this->mapping = null;
936✔
69
        $this->additional_rules = '';
936✔
70
    }
71

72

73
    /**
74
     * It assign default rules to this class. It will also reset the mapping. No exception raised.
75
     *
76
     * @param string $default_id id of the default rule. Use to avoid reinitialization same rule set
77
     * @param string $default_rules default rules
78
     */
79
    public function assign_default_rules($default_id, $default_rules) {
80
        if ($this->default_id == $default_id) {
936✔
81
            return;  // Do nothing if the rules are unchanged.
×
82
        }
83
        $this->default_id = $default_id;
936✔
84
        $this->default_rules = $default_rules;
936✔
85
        $this->default_mapping = null;
936✔
86
        $this->mapping = null;
936✔
87
        $this->additional_rules = '';   // Always remove the additional rule.
936✔
88
    }
89

90

91
    /**
92
     * Add the additional rule other than the default. Note the previous additional rule will be erased.
93
     *
94
     * @param string $additional_rules the additional rule string
95
     */
96
    public function assign_additional_rules($additional_rules) {
97
        $this->additional_rules = $additional_rules;
×
98
        $this->mapping = null;
×
99
    }
100

101

102
    /**
103
     * Parse all defined rules. It is designed to avoid unnecessary reparsing. Exception on parsing error
104
     */
105
    public function reparse_all_rules() {
106
        if ($this->default_mapping === null) {
936✔
107
            $tmp_mapping = array();
936✔
108
            $tmp_counter = 0;
936✔
109
            $this->parse_rules($tmp_mapping, $tmp_counter, $this->default_rules);
936✔
110
            $this->default_mapping = $tmp_mapping;
936✔
111
            $this->default_last_id = $tmp_counter;
936✔
112
        }
113
        if ($this->mapping === null) {
936✔
114
            $tmp_mapping = $this->default_mapping;
936✔
115
            $tmp_counter = $this->default_last_id;
936✔
116
            $this->parse_rules($tmp_mapping, $tmp_counter, $this->additional_rules);
936✔
117
            $this->mapping = $tmp_mapping;
936✔
118
        }
119
    }
120

121

122
    // Return the current unit mapping in this class.
123
    public function get_unit_mapping() {
124
        return $this->mapping;
×
125
    }
126

127

128
    // Return a dimension classes list for current mapping. Each class is an array of $unit to $scale mapping.
129
    public function get_dimension_list() {
130
        $dimension_list = array();
×
131
        foreach ($this->mapping as $unit => $class_scale) {
×
132
            list($class, $scale) = $class_scale;
×
133
            $dimension_list[$class][$unit] = $scale;
×
134
        }
135
        return $dimension_list;
×
136
    }
137

138

139
    /**
140
     * Check whether an input unit is equivalent, under conversion rules, to target units. May throw
141
     *
142
     * @param string $ipunit The input unit string
143
     * @param string $targets The list of unit separated by "=", such as "N = kg m/s^2"
144
     * @return object with three field:
145
     *   (1) convertible: true if the input unit is equivalent to the list of unit, otherwise false
146
     *   (2) cfactor: the number before ipunit has to multiply by this factor to convert a target unit.
147
     *     If the ipunit is not match to any one of target, the conversion factor is always set to 1
148
     *   (3) target: indicate the location of the matching in the $targets, if they are convertible
149
     */
150
    public function check_convertibility($ipunit, $targets) {
151
        $l1 = strlen(trim($ipunit)) == 0;
936✔
152
        $l2 = strlen(trim($targets)) == 0;
936✔
153
        if ($l1 && $l2) {
936✔
154
            // If both of them are empty, no unit check is required. i.e. they are equal.
155
            return (object) array('convertible' => true,  'cfactor' => 1, 'target' => 0);
×
156
        } else if (($l1 && !$l2) || (!$l1 && $l2)) {
936✔
157
            // If one of them is empty, they must not equal.
158
            return (object) array('convertible' => false, 'cfactor' => 1, 'target' => null);
×
159
        }
160
        // Parsing error for $ipunit is counted as not equal because it cannot match any $targets.
161
        $ip = $this->parse_unit($ipunit);
936✔
162
        if ($ip === null) {
936✔
163
            return (object) array('convertible' => false, 'cfactor' => 1, 'target' => null);
×
164
        }
165
        $this->reparse_all_rules();   // Reparse if the any rules have been updated.
936✔
166
        $targets_list = $this->parse_targets($targets);
936✔
167
        $res = $this->check_convertibility_parsed($ip, $targets_list);
936✔
168
        if ($res === null) {
936✔
169
            return (object) array('convertible' => false, 'cfactor' => 1, 'target' => null);
×
170
        } else {
171
            // For the input successfully converted to one of the unit in the $targets list.
172
            return (object) array('convertible' => true,  'cfactor' => $res[0], 'target' => $res[1]);
936✔
173
        }
174
    }
175

176

177
    /**
178
     * Parse the $targets into an array of target units. Throw on parsing error
179
     *
180
     * @param string $targets The "=" separated list of unit, such as "N = kg m/s^2"
181
     * @return an array of parsed unit, parsed by the parse_unit().
182
     */
183
    public function parse_targets($targets) {
184
        $targets_list = array();
936✔
185
        if (strlen(trim($targets)) == 0) {
936✔
186
            return $targets_list;
×
187
        }
188
        $units = explode('=', $targets);
936✔
189
        foreach ($units as $unit) {
936✔
190
            if (strlen(trim($unit) ) == 0) {
936✔
191
                throw new Exception('""');
×
192
            }
193
            $parsed_unit = $this->parse_unit($unit);
936✔
194
            if ($parsed_unit === null) {
936✔
195
                throw new Exception('"'.$unit.'"');
×
196
            }
197
            $targets_list[] = $parsed_unit;
936✔
198
        }
199
        return $targets_list;
936✔
200
    }
201

202

203
    /**
204
     * Check whether an parsed input unit $a is the same as one of the parsed unit in $target_units. No throw
205
     *
206
     * @param array $a the an array of (base unit => exponent) parsed by the parse_unit() function
207
     * @param array $targets_list an array of parsed units.
208
     * @return the array of (conversion factor, location in target list) if convertible, otherwise null
209
     */
210
    private function check_convertibility_parsed($a, $targets_list) {
211
        foreach ($targets_list as $i => $t) {   // Use exclusion method to check whether there is one match.
936✔
212
            if (count($a) != count($t)) {
936✔
213
                // If they have different number of base unit, skip.
214
                continue;
×
215
            }
216
            $cfactor = 1.;
936✔
217
            $is_all_matches = true;
936✔
218
            foreach ($a as $name => $exponent) {
936✔
219
                $unit_found = isset($t[$name]);
936✔
220
                if ($unit_found) {
936✔
221
                    $f = 1;
24✔
222
                    $e = $t[$name];         // Exponent of the target base unit.
24✔
223
                } else {     // If the base unit not match directly, try conversion.
224
                    list($f, $e) = $this->attempt_conversion($name, $t);
936✔
225
                    $unit_found = isset($f);
936✔
226
                }
227
                if (!$unit_found || abs($exponent - $e) > 0) {
936✔
228
                    $is_all_matches = false; // If unit is not found or the exponent of this dimension is wrong.
×
229
                    break;  // Stop further check.
×
230
                }
231
                $cfactor *= pow($f, $e);
936✔
232
            }
233
            if ($is_all_matches) {
936✔
234
                // All unit name and their dimension matches.
235
                return array($cfactor, $i);
936✔
236
            }
237
        }
238
        return null;   // None of the possible units match, so they are not the same.
×
239
    }
240

241

242
    /**
243
     * Attempt to convert the $test_unit_name to one of the unit in the $base_unit_array,
244
     * using any of the conversion rule added in this class earlier. No throw
245
     *
246
     * @param string $test_unit_name the name of the test unit
247
     * @param array $base_unit_array in the format of array(unit => exponent, ...)
248
     * @return null|array (conversion factor, unit exponent) if it can be converted, otherwise null.
249
     */
250
    private function attempt_conversion($test_unit_name, $base_unit_array) {
251
        // If the unit does not exist, we leave early.
252
        if (!array_key_exists($test_unit_name, $this->mapping)) {
936✔
253
            return null;
×
254
        }
255
        $oclass = $this->mapping[$test_unit_name];
936✔
256
        if (!isset($oclass)) {
936✔
257
            return null;  // It does not exist in the mapping implies it is not convertible.
×
258
        }
259
        foreach ($base_unit_array as $u => $e) {
936✔
260
            $tclass = $this->mapping[$u];   // Try to match the dimension class of each base unit.
936✔
261
            if (isset($tclass) && $oclass[0] == $tclass[0]) {
936✔
262
                return array($oclass[1] / $tclass[1], $e);
936✔
263
            }
264
        }
265
        return null;
×
266
    }
267

268

269
    /**
270
     * Split the input into the number and unit. No exception
271
     *
272
     * @param string $input physical quantity with number and unit, assume 1 if number is missing
273
     * @return object with number and unit as the field name. null if input is empty
274
     */
275
    private function split_number_unit($input) {
276
        $input = trim($input);
936✔
277
        if (strlen($input) == 0) {
936✔
278
            return null;
×
279
        }
280
        $ex = explode(' ', $input, 2);
936✔
281
        $number = $ex[0];
936✔
282
        $unit = count($ex) > 1 ? $ex[1] : null;
936✔
283
        if (is_numeric($number)) {
936✔
284
            return (object) array('number' => floatval($number), 'unit' => $unit);
936✔
285
        } else {
286
            return (object) array('number' => 1, 'unit' => $input);
936✔
287
        }
288
    }
289

290

291
    /**
292
     * Parse the unit string into a simpler pair of base unit and its exponent. No exception
293
     *
294
     * @param string $unit_expression The input unit string
295
     * @param bool $no_divisor whether divisor '/' is acceptable. It is used to parse unit recursively
296
     * @return array an array of the form (base unit name => exponent), null on error
297
     */
298
    public function parse_unit($unit_expression, $no_divisor=false) {
299
        if (strlen(trim($unit_expression)) == 0) {
936✔
300
            return array();
×
301
        }
302

303
        $pos = strpos($unit_expression, '/');
936✔
304
        if ($pos !== false) {
936✔
305
            if ($no_divisor || $pos == 0 || $pos >= strlen($unit_expression) - 1) {
72✔
306
                return null;  // Only one '/' is allowed.
×
307
            }
308
            $left = trim(substr($unit_expression, 0, $pos));
72✔
309
            $right = trim(substr($unit_expression, $pos + 1));
72✔
310
            if ($right[0] == '(' && $right[strlen($right) - 1] == ')') {
72✔
311
                $right = substr($right, 1, strlen($right) - 2);
×
312
            }
313
            $uleft = $this->parse_unit($left, true);
72✔
314
            $uright = $this->parse_unit($right, true);
72✔
315
            if ($uleft == null || $uright == null) {
72✔
316
                return null;    // If either part contains error.
×
317
            }
318
            foreach ($uright as $u => $exponent) {
72✔
319
                if (array_key_exists($u, $uleft)) {
72✔
320
                    return null;     // No duplication.
×
321
                }
322
                $uleft[$u] = -$exponent;   // Take opposite of the exponent.
72✔
323
            }
324
            return $uleft;
72✔
325
        }
326

327
        $unit = array();
936✔
328
        $unit_element_name = '([^'.self::$unit_exclude_symbols.']+)';
936✔
329
        $unit_expression = preg_replace('/\s*\^\s*/', '^', $unit_expression);
936✔
330
        $candidates = explode(' ', $unit_expression);
936✔
331
        foreach ($candidates as $candidate) {
936✔
332
            $ex = explode('^', $candidate);
936✔
333
            $name = $ex[0];     // There should be no space remaining.
936✔
334
            if (count($ex) > 1 && (strlen($name) == 0 || strlen($ex[1]) == 0)) {
936✔
335
                return null;
×
336
            }
337
            if (strlen($name) == 0) {
936✔
338
                continue;  // If it is an empty space.
×
339
            }
340
            if (!preg_match('/^'.$unit_element_name.'$/', $name)) {
936✔
341
                return null;
×
342
            }
343
            $exponent = null;
936✔
344
            if (count($ex) > 1) {
936✔
345
                if (!preg_match('/(.*)([0-9]+)(.*)/', $ex[1], $matches)) {
144✔
346
                    return null;     // Get the number of exponent.
×
347
                }
348
                if ($matches[1] == '' && $matches[3] == '') {
144✔
349
                    $exponent = intval($matches[2]);
96✔
350
                }
351
                if ($matches[1] == '-' && $matches[3] == '') {
144✔
352
                    $exponent = -intval($matches[2]);
72✔
353
                }
354
                if ($matches[1] == '(-' && $matches[3] == ')') {
144✔
355
                    $exponent = -intval($matches[2]);
×
356
                }
357
                if ($exponent == null) {
144✔
358
                    return null;    // No pattern matched.
54✔
359
                }
360
            } else {
361
                $exponent = 1;
864✔
362
            }
363
            if (array_key_exists($name, $unit)) {
936✔
364
                return null;     // No duplication.
×
365
            }
366
            $unit[$name] = $exponent;
936✔
367
        }
368
        return $unit;
936✔
369
    }
370

371

372
    /**
373
     * Parse rules into an mapping that will be used for fast lookup of unit. Exception on parsing error
374
     *
375
     * @param array $mapping an empty array, or array of unit => array(dimension class, conversion factor)
376
     * @param int $dim_id_count current number of dimension class. It will be incremented for new class
377
     * @param string $rules_string a comma separated list of rules
378
     */
379
    private function parse_rules(&$mapping, &$dim_id_count, $rules_string) {
380
        $rules = explode(';', $rules_string);
936✔
381
        foreach ($rules as $rule) {
936✔
382
            if (strlen(trim($rule)) > 0) {
936✔
383
                $unit_scales = array();
936✔
384
                $e = explode(':', $rule);
936✔
385
                if (count($e) > 3) {
936✔
386
                    throw new Exception('Syntax error of SI prefix');
×
387
                } else if (count($e) == 2) {
936✔
388
                    $unit_name = trim($e[0]);
936✔
389
                    if (preg_match('/['.self::$unit_exclude_symbols.']+/', $unit_name)) {
936✔
390
                        throw new Exception('"'.$unit_name.'" unit contains unaccepted character.');
×
391
                    }
392
                    $unit_scales[$unit_name] = 1.0;    // The original unit.
936✔
393
                    $si_prefixes = explode(' ', $e[1]);
936✔
394
                    foreach ($si_prefixes as $prefix) {
936✔
395
                        if (strlen($prefix) != 0) {
936✔
396
                            $f = self::$prefix_scale_factors[$prefix];
936✔
397
                            if (!isset($f)) {
936✔
398
                                throw new Exception('"'.$prefix.'" is not SI prefix.');
×
399
                            }
400
                            $unit_scales[$prefix.$unit_name] = $f;
936✔
401
                        }
402
                    }
403
                } else {
404
                    $data = explode('=', $rule);
936✔
405
                    foreach ($data as $d) {
936✔
406
                        $splitted = $this->split_number_unit($d);
936✔
407
                        if ($splitted === null || preg_match('/['.self::$unit_exclude_symbols.']+/', $splitted->unit)) {
936✔
408
                            throw new Exception('"'.$splitted->unit.'" unit contains unaccepted character.');
×
409
                        }
410
                        $unit_scales[trim($splitted->unit)] = 1. / floatval($splitted->number);
936✔
411
                    }
412
                }
413
                if (array_key_exists(key($unit_scales), $mapping)) {    // Is the first unit already defined?
936✔
414
                    $m = $mapping[key($unit_scales)];   // If yes, use the existing id of the same dimension class.
936✔
415
                    $dim_id = $m[0];  // This can automatically join all the previously defined unit scales.
936✔
416
                    $factor = $m[1] / current($unit_scales);    // Define the relative scale.
936✔
417
                } else { // Otherwise use a new id and define the relative scale to 1.
418
                    $dim_id = $dim_id_count++;
936✔
419
                    $factor = 1;
936✔
420
                }
421
                foreach ($unit_scales as $unit => $scale) {
936✔
422
                    // Join the new unit scale to old one, if any.
423
                    $mapping[$unit] = array($dim_id, $factor * $scale);
936✔
424
                }
425
            }
426
        }
427
    }
428

429
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc