Completed
Push — master ( dcf061...b72d09 )
by Daniel
26s
created

QuantityParser::newQuantityFromParts()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 25
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
c 4
b 0
f 0
dl 0
loc 25
rs 8.5806
cc 4
eloc 16
nc 4
nop 4
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
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|QuantityValue
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|QuantityValue
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 ( $exactness === '~' ) {
124
			// derive uncertainty from given decimals
125
			// NOTE: with scientific notation, the exponent applies to the uncertainty bounds, too
126
			$quantity = $this->newUncertainQuantityFromDigits( $amountValue, $unit, $exponent );
127
		} else {
128
			$amountValue = $this->decimalParser->applyDecimalExponent( $amountValue, $exponent );
129
			return new UnboundedQuantityValue( $amountValue, $unit );
130
		}
131
132
		return $quantity;
133
	}
134
135
	/**
136
	 * Splits a quantity string into its syntactic parts.
137
	 *
138
	 * @see newQuantityFromParts
139
	 *
140
	 * @param string $value
141
	 *
142
	 * @throws InvalidArgumentException If $value is not a string
143
	 * @throws ParseException If $value does not match the expected pattern
144
	 * @return array list( $amount, $exactness, $margin, $unit ).
145
	 *         Parts not present in $value will be null
146
	 */
147
	private function splitQuantityString( $value ) {
148
		if ( !is_string( $value ) ) {
149
			throw new InvalidArgumentException( '$value must be a string' );
150
		}
151
152
		//TODO: allow explicitly specifying the number of significant figures
153
		//TODO: allow explicitly specifying the uncertainty interval
154
155
		$numberPattern = $this->unlocalizer->getNumberRegex( '@' );
156
		$unitPattern = $this->unlocalizer->getUnitRegex( '@' );
157
158
		$pattern = '@^'
159
			. '\s*(' . $numberPattern . ')' // $1: amount
160
			. '\s*(?:'
161
				. '([~!])'  // $2: '!' for "exact", '~' for "approx", or nothing
162
				. '|(?:\+/?-|±)\s*(' . $numberPattern . ')' // $3: plus/minus offset (uncertainty margin)
163
				. '|' // or nothing
164
			. ')'
165
			. '\s*(' . $unitPattern . ')?' // $4: unit
166
			. '\s*$@u';
167
168
		if ( !preg_match( $pattern, $value, $groups ) ) {
169
			throw new ParseException( 'Malformed quantity', $value, self::FORMAT_NAME );
170
		}
171
172
		// Remove $0.
173
		array_shift( $groups );
174
175
		array_walk( $groups, function( &$element ) {
176
			if ( $element === '' ) {
177
				$element = null;
178
			}
179
		} );
180
181
		return array_pad( $groups, 4, null );
182
	}
183
184
	/**
185
	 * Returns a QuantityValue representing the given amount.
186
	 * The amount is assumed to be absolutely exact, that is,
187
	 * the upper and lower bound will be the same as the amount.
188
	 *
189
	 * @param DecimalValue $amount
190
	 * @param string $unit The quantity's unit (use "1" for unit-less quantities)
191
	 *
192
	 * @return QuantityValue
193
	 */
194
	private function newExactQuantity( DecimalValue $amount, $unit = '1' ) {
195
		return new QuantityValue( $amount, $unit, $amount, $amount );
196
	}
197
198
	/**
199
	 * Returns a QuantityValue representing the given amount, automatically assuming
200
	 * a level of uncertainty based on the digits given.
201
	 *
202
	 * The upper and lower bounds are determined automatically from the given
203
	 * digits by increasing resp. decreasing the least significant digit.
204
	 * E.g. "+0.01" would have upperBound "+0.02" and lowerBound "+0.01",
205
	 * while "-100" would have upperBound "-99" and lowerBound "-101".
206
	 *
207
	 * @param DecimalValue $amount The quantity
208
	 * @param string $unit The quantity's unit (use "1" for unit-less quantities)
209
	 * @param DecimalValue $margin
210
	 *
211
	 * @return QuantityValue
212
	 */
213
	private function newUncertainQuantityFromMargin( DecimalValue $amount, $unit = '1', DecimalValue $margin ) {
214
		$decimalMath = new DecimalMath();
215
		$margin = $margin->computeAbsolute();
216
217
		$lowerBound = $decimalMath->sum( $amount, $margin->computeComplement() );
218
		$upperBound = $decimalMath->sum( $amount, $margin );
219
220
		return new QuantityValue( $amount, $unit, $upperBound, $lowerBound );
221
	}
222
223
	/**
224
	 * Returns a QuantityValue representing the given amount, automatically assuming
225
	 * a level of uncertainty based on the digits given.
226
	 *
227
	 * The upper and lower bounds are determined automatically from the given
228
	 * digits by increasing resp. decreasing the least significant digit.
229
	 * E.g. "+0.01" would have upperBound "+0.02" and lowerBound "+0.01",
230
	 * while "-100" would have upperBound "-99" and lowerBound "-101".
231
	 *
232
	 * @param DecimalValue $amount The quantity
233
	 * @param string $unit The quantity's unit (use "1" for unit-less quantities)
234
	 * @param int $exponent Decimal exponent to apply
235
	 *
236
	 * @return QuantityValue
237
	 */
238
	private function newUncertainQuantityFromDigits( DecimalValue $amount, $unit = '1', $exponent = 0 ) {
239
		$math = new DecimalMath();
240
241
		if ( $amount->getSign() === '+' ) {
242
			$upperBound = $math->bump( $amount );
243
			$lowerBound = $math->slump( $amount );
244
		} else {
245
			$upperBound = $math->slump( $amount );
246
			$lowerBound = $math->bump( $amount );
247
		}
248
249
		$amount = $this->decimalParser->applyDecimalExponent( $amount, $exponent );
250
		$lowerBound = $this->decimalParser->applyDecimalExponent( $lowerBound, $exponent );
251
		$upperBound = $this->decimalParser->applyDecimalExponent( $upperBound, $exponent );
252
253
		return new QuantityValue( $amount, $unit, $upperBound, $lowerBound );
254
	}
255
256
}
257