Completed
Pull Request — master (#66)
by Daniel
05:21 queued 02:25
created

QuantityParser::newQuantityFromExponent()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 17
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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