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

BoundedQuantityValue::transform()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 47
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
b 0
f 0
dl 0
loc 47
rs 8.5125
cc 5
eloc 25
nc 5
nop 2
1
<?php
2
3
namespace DataValues;
4
5
use InvalidArgumentException;
6
7
/**
8
 * Class representing a quantity with associated unit and uncertainty interval.
9
 * The amount is stored as a @see DecimalValue object.
10
 *
11
 * @see QuantityValue For quantities with no known uncertainty interval.
12
 * For simple numeric amounts use @see NumberValue.
13
 *
14
 * @note BoundedQuantityValue and plain QuantityValue both use the value type ID "quantity".
15
 * The fact that we use subclassing to model the bounded vs the unbounded case should be
16
 * considered an implementation detail.
17
 *
18
 * @since 0.1
19
 *
20
 * @license GPL-2.0+
21
 * @author Daniel Kinzler
22
 */
23
class BoundedQuantityValue extends QuantityValue {
24
25
	/**
26
	 * The quantity's upper bound
27
	 *
28
	 * @var DecimalValue
29
	 */
30
	private $upperBound;
31
32
	/**
33
	 * The quantity's lower bound
34
	 *
35
	 * @var DecimalValue
36
	 */
37
	private $lowerBound;
38
39
	/**
40
	 * Constructs a new QuantityValue object, representing the given value.
41
	 *
42
	 * @since 0.1
43
	 *
44
	 * @param DecimalValue $amount
45
	 * @param string $unit A unit identifier. Must not be empty, use "1" for unit-less quantities.
46
	 * @param DecimalValue $upperBound The upper bound of the quantity, inclusive.
47
	 * @param DecimalValue $lowerBound The lower bound of the quantity, inclusive.
48
	 *
49
	 * @throws IllegalValueException
50
	 */
51
	public function __construct( DecimalValue $amount, $unit, DecimalValue $upperBound, DecimalValue $lowerBound ) {
52
		parent::__construct( $amount, $unit );
53
54
		if ( $lowerBound->compare( $amount ) > 0 ) {
0 ignored issues
show
Documentation introduced by
$amount is of type object<DataValues\DecimalValue>, but the function expects a object<self>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
55
			throw new IllegalValueException( '$lowerBound ' . $lowerBound->getValue() . ' must be <= $amount ' . $amount->getValue() );
56
		}
57
58
		if ( $upperBound->compare( $amount ) < 0 ) {
0 ignored issues
show
Documentation introduced by
$amount is of type object<DataValues\DecimalValue>, but the function expects a object<self>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
59
			throw new IllegalValueException( '$upperBound ' . $upperBound->getValue() . ' must be >= $amount ' . $amount->getValue() );
60
		}
61
62
		$this->upperBound = $upperBound;
63
		$this->lowerBound = $lowerBound;
64
	}
65
66
	/**
67
	 * Returns a QuantityValue representing the given amount.
68
	 * If no upper or lower bound is given, the amount is assumed to be absolutely exact,
69
	 * that is, the amount itself will be used as the upper and lower bound.
70
	 *
71
	 * This is a convenience wrapper around the constructor that accepts native values
72
	 * instead of DecimalValue objects.
73
	 *
74
	 * @note: if the amount or a bound is given as a string, the string must conform
75
	 * to the rules defined by @see DecimalValue.
76
	 *
77
	 * @since 0.1
78
	 *
79
	 * @param string|int|float|DecimalValue|QuantityValue $number
80
	 * @param string $unit A unit identifier. Must not be empty, use "1" for unit-less quantities.
81
	 * @param string|int|float|DecimalValue|null $upperBound
82
	 * @param string|int|float|DecimalValue|null $lowerBound
83
	 *
84
	 * @return self
85
	 * @throws IllegalValueException
86
	 */
87
	public static function newFromNumber( $number, $unit = '1', $upperBound = null, $lowerBound = null ) {
88
		$number = self::asDecimalValue( 'amount', $number );
89
		$upperBound = self::asDecimalValue( 'upperBound', $upperBound, $number );
90
		$lowerBound = self::asDecimalValue( 'lowerBound', $lowerBound, $number );
91
92
		return new self( $number, $unit, $upperBound, $lowerBound );
93
	}
94
95
	/**
96
	 * @see newFromNumber
97
	 *
98
	 * @deprecated since 0.1, use newFromNumber instead
99
	 *
100
	 * @param string|int|float|DecimalValue|QuantityValue $number
101
	 * @param string $unit
102
	 * @param string|int|float|DecimalValue|null $upperBound
103
	 * @param string|int|float|DecimalValue|null $lowerBound
104
	 *
105
	 * @return self
106
	 */
107
	public static function newFromDecimal( $number, $unit = '1', $upperBound = null, $lowerBound = null ) {
108
		return self::newFromNumber( $number, $unit, $upperBound, $lowerBound );
109
	}
110
111
	/**
112
	 * @see Serializable::unserialize
113
	 *
114
	 * @since 0.1
115
	 *
116
	 * @param string $data
117
	 */
118
	public function unserialize( $data ) {
119
		list( $amount, $unit, $upperBound, $lowerBound ) = unserialize( $data );
120
		$amount = DecimalValue::newFromArray( $amount );
121
		$upperBound = DecimalValue::newFromArray( $upperBound );
122
		$lowerBound = DecimalValue::newFromArray( $lowerBound );
123
		$this->__construct( $amount, $unit, $upperBound, $lowerBound );
124
	}
125
126
	/**
127
	 * Returns this quantity's upper bound.
128
	 *
129
	 * @since 0.1
130
	 *
131
	 * @return DecimalValue
132
	 */
133
	public function getUpperBound() {
134
		return $this->upperBound;
135
	}
136
137
	/**
138
	 * Returns this quantity's lower bound.
139
	 *
140
	 * @since 0.1
141
	 *
142
	 * @return DecimalValue
143
	 */
144
	public function getLowerBound() {
145
		return $this->lowerBound;
146
	}
147
148
	/**
149
	 * Returns the size of the uncertainty interval.
150
	 * This can roughly be interpreted as "amount +/- uncertainty/2".
151
	 *
152
	 * The exact interpretation of the uncertainty interval is left to the concrete application or
153
	 * data point. For example, the uncertainty interval may be defined to be that part of a
154
	 * normal distribution that is required to cover the 95th percentile.
155
	 *
156
	 * @since 0.1
157
	 *
158
	 * @return float
159
	 */
160
	public function getUncertainty() {
161
		return $this->upperBound->getValueFloat() - $this->lowerBound->getValueFloat();
162
	}
163
164
	/**
165
	 * Returns a DecimalValue representing the symmetrical offset to be applied
166
	 * to the raw amount for a rough representation of the uncertainty interval,
167
	 * as in "amount +/- offset".
168
	 *
169
	 * The offset is calculated as max( amount - lowerBound, upperBound - amount ).
170
	 *
171
	 * @since 0.1
172
	 *
173
	 * @return DecimalValue
174
	 */
175
	public function getUncertaintyMargin() {
176
		$math = new DecimalMath();
177
178
		$lowerMargin = $math->sum( $this->amount, $this->lowerBound->computeComplement() );
179
		$upperMargin = $math->sum( $this->upperBound, $this->amount->computeComplement() );
180
181
		$margin = $math->max( $lowerMargin, $upperMargin );
182
		return $margin;
183
	}
184
185
	/**
186
	 * Returns the order of magnitude of the uncertainty as the exponent of
187
	 * last significant digit in the amount-string. The value returned by this
188
	 * is suitable for use with @see DecimalMath::roundToExponent().
189
	 *
190
	 * @example: if two digits after the decimal point are significant, this
191
	 * returns -2.
192
	 *
193
	 * @example: if the last two digits before the decimal point are insignificant,
194
	 * this returns 2.
195
	 *
196
	 * Note that this calculation assumes a symmetric uncertainty interval,
197
	 * and can be misleading.
198
	 *
199
	 * @since 0.1
200
	 *
201
	 * @return int
202
	 */
203
	public function getOrderOfUncertainty() {
204
		// the desired precision is given by the distance between the amount and
205
		// whatever is closer, the upper or lower bound.
206
		//TODO: use DecimalMath to avoid floating point errors!
207
		$amount = $this->amount->getValueFloat();
208
		$upperBound = $this->upperBound->getValueFloat();
209
		$lowerBound = $this->lowerBound->getValueFloat();
210
		$precision = min( $amount - $lowerBound, $upperBound - $amount );
211
212
		if ( $precision === 0.0 ) {
213
			// If there is no uncertainty, the order of uncertainty is a bit more than what we have digits for.
214
			return -strlen( $this->amount->getFractionalPart() );
215
		}
216
217
		// e.g. +/- 200 -> 2; +/- 0.02 -> -2
218
		// note: we really want floor( log10( $precision ) ), but have to account for
219
		// small errors made in the floating point operations above.
220
		// @todo: use bcmath (via DecimalMath) to avoid this if possible
221
		$orderOfUncertainty = floor( log10( $precision + 0.0000000005 ) );
222
223
		return (int)$orderOfUncertainty;
224
	}
225
226
	/**
227
	 * Returns a transformed value derived from this QuantityValue by applying
228
	 * the given transformation to the amount and the upper and lower bounds.
229
	 * The resulting amount and bounds are rounded to the significant number of
230
	 * digits. Note that for exact quantities (with at least one bound equal to
231
	 * the amount), no rounding is applied (since they are considered to have
232
	 * infinite precision).
233
	 *
234
	 * The transformation is provided as a callback, which must implement a
235
	 * monotonously increasing, fully differentiable function mapping a DecimalValue
236
	 * to a DecimalValue. Typically, it will be a linear transformation applying a
237
	 * factor and an offset.
238
	 *
239
	 * @param string $newUnit The unit of the transformed quantity.
240
	 *
241
	 * @param callable $transformation A callback that implements the desired transformation.
242
	 *        The transformation will be called three times, once for the amount, once
243
	 *        for the lower bound, and once for the upper bound. It must return a DecimalValue.
244
	 *        The first parameter passed to $transformation is the DecimalValue to transform
245
	 *        In addition, any extra parameters passed to transform() will be passed through
246
	 *        to the transformation callback.
247
	 *
248
	 * @param mixed ... Any extra parameters will be passed to the $transformation function.
249
	 *
250
	 * @throws InvalidArgumentException
251
	 * @return self
252
	 */
253
	public function transform( $newUnit, $transformation ) {
254
		if ( !is_callable( $transformation ) ) {
255
			throw new InvalidArgumentException( '$transformation must be callable.' );
256
		}
257
258
		if ( !is_string( $newUnit ) ) {
259
			throw new InvalidArgumentException( '$newUnit must be a string. Use "1" as the unit for unit-less quantities.' );
260
		}
261
262
		if ( $newUnit === '' ) {
263
			throw new InvalidArgumentException( '$newUnit must not be empty. Use "1" as the unit for unit-less quantities.' );
264
		}
265
266
		$oldUnit = $this->unit;
267
268
		if ( $newUnit === null ) {
269
			$newUnit = $oldUnit;
270
		}
271
272
		// Apply transformation by calling the $transform callback.
273
		// The first argument for the callback is the DataValue to transform. In addition,
274
		// any extra arguments given for transform() are passed through.
275
		$args = func_get_args();
276
		array_shift( $args );
277
278
		$args[0] = $this->amount;
279
		$amount = call_user_func_array( $transformation, $args );
280
281
		$args[0] = $this->upperBound;
282
		$upperBound = call_user_func_array( $transformation, $args );
283
284
		$args[0] = $this->lowerBound;
285
		$lowerBound = call_user_func_array( $transformation, $args );
286
287
		// use a preliminary QuantityValue to determine the significant number of digits
288
		$transformed = new self( $amount, $newUnit, $upperBound, $lowerBound );
289
		$roundingExponent = $transformed->getOrderOfUncertainty();
290
291
		// apply rounding to the significant digits
292
		$math = new DecimalMath();
293
294
		$amount = $math->roundToExponent( $amount, $roundingExponent );
295
		$upperBound = $math->roundToExponent( $upperBound, $roundingExponent );
296
		$lowerBound = $math->roundToExponent( $lowerBound, $roundingExponent );
297
298
		return new self( $amount, $newUnit, $upperBound, $lowerBound );
299
	}
300
301
	public function __toString() {
302
		return $this->amount->getValue()
303
			. '[' . $this->lowerBound->getValue()
304
			. '..' . $this->upperBound->getValue()
305
			. ']'
306
			. ( $this->unit === '1' ? '' : $this->unit );
307
	}
308
309
	/**
310
	 * @see DataValue::getArrayValue
311
	 *
312
	 * @since 0.1
313
	 *
314
	 * @return array
315
	 */
316
	public function getArrayValue() {
317
		return array(
318
			'amount' => $this->amount->getArrayValue(),
319
			'unit' => $this->unit,
320
			'upperBound' => $this->upperBound->getArrayValue(),
321
			'lowerBound' => $this->lowerBound->getArrayValue(),
322
		);
323
	}
324
325
	/**
326
	 * Constructs a new instance of the DataValue from the provided data.
327
	 * This can round-trip with @see getArrayValue
328
	 *
329
	 * @since 0.1
330
	 *
331
	 * @param mixed $data
332
	 *
333
	 * @return self
334
	 * @throws IllegalValueException
335
	 */
336
	public static function newFromArray( $data ) {
337
		if ( !isset( $data['upperBound'] ) && isset( $data['lowerBound'] ) ) {
338
			// No bounds given, so construct an unbounded QuantityValue.
339
			return parent::newFromArray( $data );
340
		}
341
342
		self::requireArrayFields( $data, array( 'amount', 'unit', 'upperBound', 'lowerBound' ) );
343
344
		return new static(
345
			DecimalValue::newFromArray( $data['amount'] ),
346
			$data['unit'],
347
			DecimalValue::newFromArray( $data['upperBound'] ),
348
			DecimalValue::newFromArray( $data['lowerBound'] )
349
		);
350
	}
351
352
}
353