QuantityParser   A
last analyzed

Complexity

Total Complexity 20

Size/Duplication

Total Lines 240
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 9

Test Coverage

Coverage 97.3%

Importance

Changes 0
Metric Value
wmc 20
lcom 1
cbo 9
dl 0
loc 240
ccs 72
cts 74
cp 0.973
rs 10
c 0
b 0
f 0

8 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 8 2
A stringParse() 0 18 6
A getUnitFromOptions() 0 4 2
A newQuantityFromParts() 0 25 4
A splitQuantityString() 0 32 3
A newExactQuantity() 0 3 1
A newUncertainQuantityFromMargin() 0 9 1
A newUncertainQuantityFromDigits() 0 22 1
1
<?php
2
3
namespace ValueParsers;
4
5
use DataValues\DecimalMath;
6
use DataValues\DecimalValue;
7
use DataValues\IllegalValueException;
8
use DataValues\QuantityValue;
9
use DataValues\UnboundedQuantityValue;
10
11
/**
12
 * ValueParser that parses the string representation of a quantity.
13
 *
14
 * @since 0.1
15
 *
16
 * @license GPL-2.0-or-later
17
 * @author Daniel Kinzler
18
 */
19
class QuantityParser extends StringValueParser {
20
21
	public const FORMAT_NAME = 'quantity';
22
23
	/**
24
	 * The unit of the value to parse. If this option is given, it's illegal to also specify
25
	 * a unit in the input string.
26
	 *
27
	 * @since 0.5
28
	 */
29
	public const OPT_UNIT = 'unit';
30
31
	/**
32
	 * @var DecimalParser
33
	 */
34
	private $decimalParser;
35
36
	/**
37
	 * @var NumberUnlocalizer
38
	 */
39
	private $unlocalizer;
40
41
	/**
42
	 * @since 0.1
43
	 *
44
	 * @param ParserOptions|null $options
45
	 * @param NumberUnlocalizer|null $unlocalizer
46
	 */
47 99
	public function __construct( ParserOptions $options = null, NumberUnlocalizer $unlocalizer = null ) {
48 99
		parent::__construct( $options );
49
50 99
		$this->defaultOption( self::OPT_UNIT, null );
51
52 99
		$this->unlocalizer = $unlocalizer ?: new BasicNumberUnlocalizer();
53 99
		$this->decimalParser = new DecimalParser( $this->options, $this->unlocalizer );
54 99
	}
55
56
	/**
57
	 * @see StringValueParser::stringParse
58
	 *
59
	 * @since 0.1
60
	 *
61
	 * @param string $value
62
	 *
63
	 * @return UnboundedQuantityValue|QuantityValue
64
	 * @throws ParseException
65
	 */
66 92
	protected function stringParse( $value ) {
67 92
		list( $amount, $exactness, $margin, $unit ) = $this->splitQuantityString( $value );
68
69 59
		$unitOption = $this->getUnitFromOptions();
70
71 59
		if ( $unit === null ) {
72 41
			$unit = $unitOption !== null ? $unitOption : '1';
73 18
		} elseif ( $unitOption !== null && $unit !== $unitOption ) {
74 3
			throw new ParseException( 'Cannot specify a unit in input if a unit was fixed via options.' );
75
		}
76
77
		try {
78 56
			$quantity = $this->newQuantityFromParts( $amount, $exactness, $margin, $unit );
79 56
			return $quantity;
80
		} catch ( IllegalValueException $ex ) {
81
			throw new ParseException( $ex->getMessage(), $value, self::FORMAT_NAME );
82
		}
83
	}
84
85
	/**
86
	 * @return string|null
87
	 */
88 59
	private function getUnitFromOptions() {
89 59
		$unit = $this->getOption( self::OPT_UNIT );
90 59
		return $unit === null ? null : trim( $unit );
91
	}
92
93
	/**
94
	 * Constructs a QuantityValue from the given parts.
95
	 *
96
	 * @see splitQuantityString
97
	 *
98
	 * @param string $amount decimal representation of the amount
99
	 * @param string|null $exactness either '!' to indicate an exact value,
100
	 *        or '~' for "automatic", or null if $margin should be used.
101
	 * @param string|null $margin decimal representation of the uncertainty margin
102
	 * @param string $unit the unit identifier (use "1" for unitless quantities).
103
	 *
104
	 * @throws ParseException if one of the decimals could not be parsed.
105
	 * @throws IllegalValueException if the QuantityValue could not be constructed
106
	 * @return UnboundedQuantityValue|QuantityValue
107
	 */
108 56
	private function newQuantityFromParts( $amount, $exactness, $margin, $unit ) {
109 56
		list( $amount, $exponent ) = $this->decimalParser->splitDecimalExponent( $amount );
110 56
		$amountValue = $this->decimalParser->parse( $amount );
111
112 56
		if ( $exactness === '!' ) {
113
			// the amount is an exact number
114 5
			$amountValue = $this->decimalParser->applyDecimalExponent( $amountValue, $exponent );
115 5
			$quantity = $this->newExactQuantity( $amountValue, $unit );
116 51
		} elseif ( $margin !== null ) {
117
			// uncertainty margin given
118
			// NOTE: the pattern for scientific notation is 2e3 +/- 1e2, so the exponents are treated separately.
119 13
			$marginValue = $this->decimalParser->parse( $margin );
120 13
			$amountValue = $this->decimalParser->applyDecimalExponent( $amountValue, $exponent );
121 13
			$quantity = $this->newUncertainQuantityFromMargin( $amountValue, $marginValue, $unit );
122 38
		} elseif ( $exactness === '~' ) {
123
			// derive uncertainty from given decimals
124
			// NOTE: with scientific notation, the exponent applies to the uncertainty bounds, too
125 4
			$quantity = $this->newUncertainQuantityFromDigits( $amountValue, $unit, $exponent );
126
		} else {
127 34
			$amountValue = $this->decimalParser->applyDecimalExponent( $amountValue, $exponent );
128 34
			return new UnboundedQuantityValue( $amountValue, $unit );
129
		}
130
131 22
		return $quantity;
132
	}
133
134
	/**
135
	 * Splits a quantity string into its syntactic parts.
136
	 *
137
	 * @see newQuantityFromParts
138
	 *
139
	 * @param string $value
140
	 *
141
	 * @throws ParseException If $value does not match the expected pattern
142
	 * @return array list( $amount, $exactness, $margin, $unit ).
143
	 *         Parts not present in $value will be null
144
	 */
145 92
	private function splitQuantityString( $value ) {
146
		//TODO: allow explicitly specifying the number of significant figures
147
		//TODO: allow explicitly specifying the uncertainty interval
148
149 92
		$numberPattern = $this->unlocalizer->getNumberRegex( '@' );
150 92
		$unitPattern = $this->unlocalizer->getUnitRegex( '@' );
151
152
		$pattern = '@^'
153 92
			. '\s*(' . $numberPattern . ')' // $1: amount
154 92
			. '\s*(?:'
155 92
				. '([~!])'  // $2: '!' for "exact", '~' for "approx", or nothing
156 92
				. '|(?:\+/?-|±)\s*(' . $numberPattern . ')' // $3: plus/minus offset (uncertainty margin)
157 92
				. '|' // or nothing
158 92
			. ')'
159 92
			. '\s*(' . $unitPattern . ')?' // $4: unit
160 92
			. '\s*$@u';
161
162 92
		if ( !preg_match( $pattern, $value, $groups ) ) {
163 33
			throw new ParseException( 'Malformed quantity', $value, self::FORMAT_NAME );
164
		}
165
166
		// Remove $0.
167 59
		array_shift( $groups );
168
169 59
		array_walk( $groups, function ( &$element ) {
170 59
			if ( $element === '' ) {
171 28
				$element = null;
172
			}
173 59
		} );
174
175 59
		return array_pad( $groups, 4, null );
176
	}
177
178
	/**
179
	 * Returns a QuantityValue representing the given amount.
180
	 * The amount is assumed to be absolutely exact, that is,
181
	 * the upper and lower bound will be the same as the amount.
182
	 *
183
	 * @param DecimalValue $amount
184
	 * @param string $unit The quantity's unit (use "1" for unit-less quantities)
185
	 *
186
	 * @return QuantityValue
187
	 */
188 5
	private function newExactQuantity( DecimalValue $amount, $unit = '1' ) {
189 5
		return new QuantityValue( $amount, $unit, $amount, $amount );
190
	}
191
192
	/**
193
	 * Returns a QuantityValue representing the given amount, automatically assuming
194
	 * a level of uncertainty based on the digits given.
195
	 *
196
	 * The upper and lower bounds are determined automatically from the given
197
	 * digits by increasing resp. decreasing the least significant digit.
198
	 * E.g. "+0.01" would have upperBound "+0.02" and lowerBound "+0.01",
199
	 * while "-100" would have upperBound "-99" and lowerBound "-101".
200
	 *
201
	 * @param DecimalValue $amount The quantity
202
	 * @param string $unit The quantity's unit (use "1" for unit-less quantities)
203
	 * @param DecimalValue $margin
204
	 *
205
	 * @return QuantityValue
206
	 */
207 13
	private function newUncertainQuantityFromMargin( DecimalValue $amount, DecimalValue $margin, $unit = '1' ) {
208 13
		$decimalMath = new DecimalMath();
209 13
		$margin = $margin->computeAbsolute();
210
211 13
		$lowerBound = $decimalMath->sum( $amount, $margin->computeComplement() );
212 13
		$upperBound = $decimalMath->sum( $amount, $margin );
213
214 13
		return new QuantityValue( $amount, $unit, $upperBound, $lowerBound );
215
	}
216
217
	/**
218
	 * Returns a QuantityValue representing the given amount, automatically assuming
219
	 * a level of uncertainty based on the digits given.
220
	 *
221
	 * The upper and lower bounds are determined automatically from the given
222
	 * digits by adding/subtracting half the order of magnitude of the least
223
	 * significant digit. Trailing zeros before the decimal point are considered
224
	 * significant.
225
	 *
226
	 * E.g. "+0.01" would have upperBound "+0.015" and lowerBound "+0.005",
227
	 * while "-100" would have upperBound "-99.5" and lowerBound "-100.5".
228
	 *
229
	 * @param DecimalValue $amount The quantity
230
	 * @param string $unit The quantity's unit (use "1" for unit-less quantities)
231
	 * @param int $exponent Decimal exponent to apply
232
	 *
233
	 * @return QuantityValue
234
	 */
235 4
	private function newUncertainQuantityFromDigits( DecimalValue $amount, $unit = '1', $exponent = 0 ) {
236 4
		$math = new DecimalMath();
237
238
		// Add/subtract one from least significant digit
239 4
		$high = $math->bump( $amount );
240 4
		$low = $math->slump( $amount );
241
242
		// Compute margin = abs( high - low ) / 4.
243 4
		$highLow = $math->sum( $high, $low->computeComplement() )->computeAbsolute();
244 4
		$margin = $math->product( $highLow, new DecimalValue( '0.25' ) );
245
246
		// Bounds = amount +/- margin
247 4
		$upperBound = $math->sum( $amount, $margin )->getTrimmed();
248 4
		$lowerBound = $math->sum( $amount, $margin->computeComplement() )->getTrimmed();
249
250
		// Apply exponent
251 4
		$amount = $this->decimalParser->applyDecimalExponent( $amount, $exponent );
252 4
		$lowerBound = $this->decimalParser->applyDecimalExponent( $lowerBound, $exponent );
253 4
		$upperBound = $this->decimalParser->applyDecimalExponent( $upperBound, $exponent );
254
255 4
		return new QuantityValue( $amount, $unit, $upperBound, $lowerBound );
256
	}
257
258
}
259