Test Failed
Push — BoundedQuantityValue ( 72482a...61280d )
by Daniel
02:23
created

QuantityParser::newUncertainQuantityFromDigits()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 17
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
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\DecimalMath;
6
use DataValues\DecimalValue;
7
use DataValues\IllegalValueException;
8
use DataValues\QuantityValue;
9
use InvalidArgumentException;
10
11
/**
12
 * ValueParser that parses the string representation of a quantity.
13
 *
14
 * @since 0.1
15
 *
16
 * @license GPL-2.0+
17
 * @author Daniel Kinzler
18
 */
19
class QuantityParser extends StringValueParser {
20
21
	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
	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
	public function __construct( ParserOptions $options = null, NumberUnlocalizer $unlocalizer = null ) {
48
		parent::__construct( $options );
49
50
		$this->defaultOption( self::OPT_UNIT, null );
51
52
		$this->unlocalizer = $unlocalizer ?: new BasicNumberUnlocalizer();
53
		$this->decimalParser = new DecimalParser( $this->options, $this->unlocalizer );
54
	}
55
56
	/**
57
	 * @see StringValueParser::stringParse
58
	 *
59
	 * @since 0.1
60
	 *
61
	 * @param string $value
62
	 *
63
	 * @return QuantityValue
64
	 * @throws ParseException
65
	 */
66
	protected function stringParse( $value ) {
67
		list( $amount, $exactness, $margin, $unit ) = $this->splitQuantityString( $value );
68
69
		$unitOption = $this->getUnitFromOptions();
70
71
		if ( $unit === null ) {
72
			$unit = $unitOption !== null ? $unitOption : '1';
73
		} elseif ( $unitOption !== null && $unit !== $unitOption ) {
74
			throw new ParseException( 'Cannot specify a unit in input if a unit was fixed via options.' );
75
		}
76
77
		try {
78
			$quantity = $this->newQuantityFromParts( $amount, $exactness, $margin, $unit );
79
			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
	private function getUnitFromOptions() {
89
		$unit = $this->getOption( self::OPT_UNIT );
90
		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 QuantityValue
107
	 */
108
	private function newQuantityFromParts( $amount, $exactness, $margin, $unit ) {
109
		list( $amount, $exponent ) = $this->decimalParser->splitDecimalExponent( $amount );
110
		$amountValue = $this->decimalParser->parse( $amount );
111
112
		if ( $exactness === '!' ) {
113
			// the amount is an exact number
114
			$amountValue = $this->decimalParser->applyDecimalExponent( $amountValue, $exponent );
115
			$quantity = $this->newExactQuantity( $amountValue, $unit );
116
		} elseif ( $margin !== null ) {
117
			// uncertainty margin given
118
			// NOTE: the pattern for scientific notation is 2e3 +/- 1e2, so the exponents are treated separately.
119
			$marginValue = $this->decimalParser->parse( $margin );
120
			$amountValue = $this->decimalParser->applyDecimalExponent( $amountValue, $exponent );
121
			$quantity = $this->newUncertainQuantityFromMargin( $amountValue, $unit, $marginValue );
122
		} else {
123
			// derive uncertainty from given decimals
124
			// NOTE: with scientific notation, the exponent applies to the uncertainty bounds, too
125
			$quantity = $this->newUncertainQuantityFromDigits( $amountValue, $unit, $exponent );
126
		}
127
128
		return $quantity;
129
	}
130
131
	/**
132
	 * Splits a quantity string into its syntactic parts.
133
	 *
134
	 * @see newQuantityFromParts
135
	 *
136
	 * @param string $value
137
	 *
138
	 * @throws InvalidArgumentException If $value is not a string
139
	 * @throws ParseException If $value does not match the expected pattern
140
	 * @return array list( $amount, $exactness, $margin, $unit ).
141
	 *         Parts not present in $value will be null
142
	 */
143
	private function splitQuantityString( $value ) {
144
		if ( !is_string( $value ) ) {
145
			throw new InvalidArgumentException( '$value must be a string' );
146
		}
147
148
		//TODO: allow explicitly specifying the number of significant figures
149
		//TODO: allow explicitly specifying the uncertainty interval
150
151
		$numberPattern = $this->unlocalizer->getNumberRegex( '@' );
152
		$unitPattern = $this->unlocalizer->getUnitRegex( '@' );
153
154
		$pattern = '@^'
155
			. '\s*(' . $numberPattern . ')' // $1: amount
156
			. '\s*(?:'
157
				. '([~!])'  // $2: '!' for "exact", '~' for "approx", or nothing
158
				. '|(?:\+/?-|±)\s*(' . $numberPattern . ')' // $3: plus/minus offset (uncertainty margin)
159
				. '|' // or nothing
160
			. ')'
161
			. '\s*(' . $unitPattern . ')?' // $4: unit
162
			. '\s*$@u';
163
164
		if ( !preg_match( $pattern, $value, $groups ) ) {
165
			throw new ParseException( 'Malformed quantity', $value, self::FORMAT_NAME );
166
		}
167
168
		// Remove $0.
169
		array_shift( $groups );
170
171
		array_walk( $groups, function( &$element ) {
172
			if ( $element === '' ) {
173
				$element = null;
174
			}
175
		} );
176
177
		return array_pad( $groups, 4, null );
178
	}
179
180
	/**
181
	 * Returns a QuantityValue representing the given amount.
182
	 * The amount is assumed to be absolutely exact, that is,
183
	 * the upper and lower bound will be the same as the amount.
184
	 *
185
	 * @param DecimalValue $amount
186
	 * @param string $unit The quantity's unit (use "1" for unit-less quantities)
187
	 *
188
	 * @return QuantityValue
189
	 */
190
	private function newExactQuantity( DecimalValue $amount, $unit = '1' ) {
191
		return new QuantityValue( $amount, $unit, $amount, $amount );
192
	}
193
194
	/**
195
	 * Returns a QuantityValue representing the given amount, automatically assuming
196
	 * a level of uncertainty based on the digits given.
197
	 *
198
	 * The upper and lower bounds are determined automatically from the given
199
	 * digits by increasing resp. decreasing the least significant digit.
200
	 * E.g. "+0.01" would have upperBound "+0.02" and lowerBound "+0.01",
201
	 * while "-100" would have upperBound "-99" and lowerBound "-101".
202
	 *
203
	 * @param DecimalValue $amount The quantity
204
	 * @param string $unit The quantity's unit (use "1" for unit-less quantities)
205
	 * @param DecimalValue $margin
206
	 *
207
	 * @return QuantityValue
208
	 */
209
	private function newUncertainQuantityFromMargin( DecimalValue $amount, $unit = '1', DecimalValue $margin ) {
210
		$decimalMath = new DecimalMath();
211
		$margin = $margin->computeAbsolute();
212
213
		$lowerBound = $decimalMath->sum( $amount, $margin->computeComplement() );
214
		$upperBound = $decimalMath->sum( $amount, $margin );
215
216
		return new QuantityValue( $amount, $unit, $upperBound, $lowerBound );
217
	}
218
219
	/**
220
	 * Returns a QuantityValue representing the given amount, automatically assuming
221
	 * a level of uncertainty based on the digits given.
222
	 *
223
	 * The upper and lower bounds are determined automatically from the given
224
	 * digits by increasing resp. decreasing the least significant digit.
225
	 * E.g. "+0.01" would have upperBound "+0.02" and lowerBound "+0.01",
226
	 * while "-100" would have upperBound "-99" and lowerBound "-101".
227
	 *
228
	 * @param DecimalValue $amount The quantity
229
	 * @param string $unit The quantity's unit (use "1" for unit-less quantities)
230
	 * @param int $exponent Decimal exponent to apply
231
	 *
232
	 * @return QuantityValue
233
	 */
234
	private function newUncertainQuantityFromDigits( DecimalValue $amount, $unit = '1', $exponent = 0 ) {
235
		$math = new DecimalMath();
236
237
		if ( $amount->getSign() === '+' ) {
238
			$upperBound = $math->bump( $amount );
239
			$lowerBound = $math->slump( $amount );
240
		} else {
241
			$upperBound = $math->slump( $amount );
242
			$lowerBound = $math->bump( $amount );
243
		}
244
245
		$amount = $this->decimalParser->applyDecimalExponent( $amount, $exponent );
246
		$lowerBound = $this->decimalParser->applyDecimalExponent( $lowerBound, $exponent );
247
		$upperBound = $this->decimalParser->applyDecimalExponent( $upperBound, $exponent );
248
249
		return new QuantityValue( $amount, $unit, $upperBound, $lowerBound );
250
	}
251
252
}
253