Completed
Pull Request — master (#55)
by no
06:29 queued 03:19
created

src/DataValues/QuantityValue.php (2 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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
 * For simple numeric amounts use @see NumberValue.
12
 *
13
 * @since 0.1
14
 *
15
 * @license GPL-2.0+
16
 * @author Daniel Kinzler
17
 * @author Thiemo Mättig
18
 */
19
class QuantityValue extends DataValueObject {
20
21
	/**
22
	 * The quantity's amount
23
	 *
24
	 * @var DecimalValue
25
	 */
26
	private $amount;
27
28
	/**
29
	 * The quantity's unit identifier (use "1" for unitless quantities).
30
	 *
31
	 * @var string
32
	 */
33
	private $unit;
34
35
	/**
36
	 * The quantity's upper bound
37
	 *
38
	 * @var DecimalValue|null
39
	 */
40
	private $upperBound;
41
42
	/**
43
	 * The quantity's lower bound
44
	 *
45
	 * @var DecimalValue|null
46
	 */
47
	private $lowerBound;
48
49
	/**
50
	 * Constructs a new QuantityValue object, representing the given value.
51
	 *
52
	 * @since 0.1
53
	 *
54
	 * @param DecimalValue $amount
55
	 * @param string $unit A unit identifier. Must not be empty, use "1" for unit-less quantities.
56
	 * @param DecimalValue|null $upperBound The upper bound of the quantity, inclusive.
57
	 * @param DecimalValue|null $lowerBound The lower bound of the quantity, inclusive.
58
	 *
59
	 * @throws IllegalValueException
60
	 */
61
	public function __construct(
62
		DecimalValue $amount,
63
		$unit,
64
		DecimalValue $upperBound = null,
65
		DecimalValue $lowerBound = null
66
	) {
67
		if ( is_null( $upperBound ) !== is_null( $lowerBound ) ) {
68
			throw new IllegalValueException( '$upperBound and $lowerBound must both be defined or undefined' );
69
		}
70
71
		if ( $lowerBound && $lowerBound->compare( $amount ) > 0 ) {
0 ignored issues
show
$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...
72
			throw new IllegalValueException( '$lowerBound ' . $lowerBound->getValue() . ' must be <= $amount ' . $amount->getValue() );
73
		}
74
75
		if ( $upperBound && $upperBound->compare( $amount ) < 0 ) {
0 ignored issues
show
$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...
76
			throw new IllegalValueException( '$upperBound ' . $upperBound->getValue() . ' must be >= $amount ' . $amount->getValue() );
77
		}
78
79
		if ( !is_string( $unit ) ) {
80
			throw new IllegalValueException( '$unit needs to be a string, not ' . gettype( $unit ) );
81
		}
82
83
		if ( $unit === '' ) {
84
			throw new IllegalValueException( '$unit can not be an empty string (use "1" for unit-less quantities)' );
85
		}
86
87
		$this->amount = $amount;
88
		$this->unit = $unit;
89
		$this->upperBound = $upperBound;
90
		$this->lowerBound = $lowerBound;
91
	}
92
93
	/**
94
	 * Returns a QuantityValue representing the given amount.
95
	 * If no upper or lower bound is given, the amount is assumed to be absolutely exact,
96
	 * that is, the amount itself will be used as the upper and lower bound.
97
	 *
98
	 * This is a convenience wrapper around the constructor that accepts native values
99
	 * instead of DecimalValue objects.
100
	 *
101
	 * @note: if the amount or a bound is given as a string, the string must conform
102
	 * to the rules defined by @see DecimalValue.
103
	 *
104
	 * @since 0.1
105
	 *
106
	 * @param string|int|float|DecimalValue $amount
107
	 * @param string $unit A unit identifier. Must not be empty, use "1" for unit-less quantities.
108
	 * @param string|int|float|DecimalValue|null $upperBound
109
	 * @param string|int|float|DecimalValue|null $lowerBound
110
	 *
111
	 * @return self
112
	 * @throws IllegalValueException
113
	 */
114
	public static function newFromNumber( $amount, $unit = '1', $upperBound = null, $lowerBound = null ) {
115
		$amount = self::asDecimalValue( 'amount', $amount );
116
117
		if ( $upperBound !== null ) {
118
			$upperBound = self::asDecimalValue( 'upperBound', $upperBound );
119
		}
120
121
		if ( $lowerBound !== null ) {
122
			$lowerBound = self::asDecimalValue( 'lowerBound', $lowerBound );
123
		}
124
125
		return new self( $amount, $unit, $upperBound, $lowerBound );
126
	}
127
128
	/**
129
	 * @see newFromNumber
130
	 *
131
	 * @deprecated since 0.1, use newFromNumber instead
132
	 *
133
	 * @param string|int|float|DecimalValue $amount
134
	 * @param string $unit
135
	 * @param string|int|float|DecimalValue|null $upperBound
136
	 * @param string|int|float|DecimalValue|null $lowerBound
137
	 *
138
	 * @return self
139
	 */
140
	public static function newFromDecimal( $amount, $unit = '1', $upperBound = null, $lowerBound = null ) {
141
		return self::newFromNumber( $amount, $unit, $upperBound, $lowerBound );
142
	}
143
144
	/**
145
	 * Converts $number to a DecimalValue if possible and necessary.
146
	 *
147
	 * @note: if the $number is given as a string, it must conform to the rules
148
	 *        defined by @see DecimalValue.
149
	 *
150
	 * @param string $name The variable name to use in exception messages
151
	 * @param string|int|float|DecimalValue $number
152
	 *
153
	 * @throws IllegalValueException
154
	 * @throws InvalidArgumentException
155
	 * @return DecimalValue
156
	 */
157
	private static function asDecimalValue( $name, $number ) {
158
		if ( !is_string( $name ) ) {
159
			throw new InvalidArgumentException( '$name must be a string' );
160
		}
161
162
		if ( $number instanceof DecimalValue ) {
163
			// nothing to do
164
		} elseif ( is_int( $number ) || is_float( $number ) || is_string( $number ) ) {
165
			$number = new DecimalValue( $number );
166
		} else {
167
			throw new IllegalValueException( '$' . $name . '  must be a string, int, or float' );
168
		}
169
170
		return $number;
171
	}
172
173
	/**
174
	 * @see Serializable::serialize
175
	 *
176
	 * @since 0.1
177
	 *
178
	 * @return string
179
	 */
180
	public function serialize() {
181
		return serialize( array(
182
			$this->amount,
183
			$this->unit,
184
			$this->upperBound,
185
			$this->lowerBound,
186
		) );
187
	}
188
189
	/**
190
	 * @see Serializable::unserialize
191
	 *
192
	 * @since 0.1
193
	 *
194
	 * @param string $data
195
	 */
196
	public function unserialize( $data ) {
197
		list( $amount, $unit, $upperBound, $lowerBound ) = unserialize( $data );
198
		$this->__construct( $amount, $unit, $upperBound, $lowerBound );
199
	}
200
201
	/**
202
	 * @see DataValue::getType
203
	 *
204
	 * @since 0.1
205
	 *
206
	 * @return string
207
	 */
208
	public static function getType() {
209
		return 'quantity';
210
	}
211
212
	/**
213
	 * @see DataValue::getSortKey
214
	 *
215
	 * @since 0.1
216
	 *
217
	 * @return float
218
	 */
219
	public function getSortKey() {
220
		return $this->amount->getValueFloat();
221
	}
222
223
	/**
224
	 * Returns the quantity object.
225
	 * @see DataValue::getValue
226
	 *
227
	 * @since 0.1
228
	 *
229
	 * @return self
230
	 */
231
	public function getValue() {
232
		return $this;
233
	}
234
235
	/**
236
	 * Returns the amount represented by this quantity.
237
	 *
238
	 * @since 0.1
239
	 *
240
	 * @return DecimalValue
241
	 */
242
	public function getAmount() {
243
		return $this->amount;
244
	}
245
246
	/**
247
	 * Returns this quantity's upper bound.
248
	 *
249
	 * @since 0.1
250
	 *
251
	 * @return DecimalValue|null
252
	 */
253
	public function getUpperBound() {
254
		return $this->upperBound;
255
	}
256
257
	/**
258
	 * Returns this quantity's lower bound.
259
	 *
260
	 * @since 0.1
261
	 *
262
	 * @return DecimalValue|null
263
	 */
264
	public function getLowerBound() {
265
		return $this->lowerBound;
266
	}
267
268
	/**
269
	 * Returns the size of the uncertainty interval.
270
	 * This can roughly be interpreted as "amount +/- uncertainty/2".
271
	 *
272
	 * The exact interpretation of the uncertainty interval is left to the concrete application or
273
	 * data point. For example, the uncertainty interval may be defined to be that part of a
274
	 * normal distribution that is required to cover the 95th percentile.
275
	 *
276
	 * @since 0.1
277
	 *
278
	 * @return float|null
279
	 */
280
	public function getUncertainty() {
281
		return $this->upperBound
282
			? $this->upperBound->getValueFloat() - $this->lowerBound->getValueFloat()
283
			: null;
284
	}
285
286
	/**
287
	 * Returns a DecimalValue representing the symmetrical offset to be applied
288
	 * to the raw amount for a rough representation of the uncertainty interval,
289
	 * as in "amount +/- offset".
290
	 *
291
	 * The offset is calculated as max( amount - lowerBound, upperBound - amount ).
292
	 *
293
	 * @since 0.1
294
	 *
295
	 * @return DecimalValue|null
296
	 */
297
	public function getUncertaintyMargin() {
298
		if ( $this->upperBound === null ) {
299
			return null;
300
		}
301
302
		$math = new DecimalMath();
303
304
		$lowerMargin = $math->sum( $this->amount, $this->lowerBound->computeComplement() );
305
		$upperMargin = $math->sum( $this->upperBound, $this->amount->computeComplement() );
306
307
		$margin = $math->max( $lowerMargin, $upperMargin );
308
		return $margin;
309
	}
310
311
	/**
312
	 * Returns the order of magnitude of the uncertainty as the exponent of
313
	 * last significant digit in the amount-string. The value returned by this
314
	 * is suitable for use with @see DecimalMath::roundToExponent().
315
	 *
316
	 * @example: if two digits after the decimal point are significant, this
317
	 * returns -2.
318
	 *
319
	 * @example: if the last two digits before the decimal point are insignificant,
320
	 * this returns 2.
321
	 *
322
	 * Note that this calculation assumes a symmetric uncertainty interval,
323
	 * and can be misleading.
324
	 *
325
	 * @since 0.1
326
	 *
327
	 * @return int|null
328
	 */
329
	public function getOrderOfUncertainty() {
330
		if ( $this->upperBound === null ) {
331
			return null;
332
		}
333
334
		// the desired precision is given by the distance between the amount and
335
		// whatever is closer, the upper or lower bound.
336
		//TODO: use DecimalMath to avoid floating point errors!
337
		$amount = $this->amount->getValueFloat();
338
		$upperBound = $this->upperBound->getValueFloat();
339
		$lowerBound = $this->lowerBound->getValueFloat();
340
		$precision = min( $amount - $lowerBound, $upperBound - $amount );
341
342
		if ( $precision === 0.0 ) {
343
			// If there is no uncertainty, the order of uncertainty is a bit more than what we have digits for.
344
			return -strlen( $this->amount->getFractionalPart() );
345
		}
346
347
		// e.g. +/- 200 -> 2; +/- 0.02 -> -2
348
		// note: we really want floor( log10( $precision ) ), but have to account for
349
		// small errors made in the floating point operations above.
350
		// @todo: use bcmath (via DecimalMath) to avoid this if possible
351
		$orderOfUncertainty = floor( log10( $precision + 0.0000000005 ) );
352
353
		return (int)$orderOfUncertainty;
354
	}
355
356
	/**
357
	 * Returns the number of significant figures in the amount-string,
358
	 * counting the decimal point, but not counting the leading sign.
359
	 *
360
	 * Note that this calculation assumes a symmetric uncertainty interval, and can be misleading
361
	 *
362
	 * @since 0.1
363
	 *
364
	 * @return int|null
365
	 */
366
	public function getSignificantFigures() {
367
		$math = new DecimalMath();
368
369
		// $orderOfUncertainty is +/- 200 -> 2; +/- 0.02 -> -2
370
		$orderOfUncertainty = $this->getOrderOfUncertainty();
371
372
		if ( $orderOfUncertainty === null ) {
373
			return null;
374
		}
375
376
		// the number of digits (without the sign) is the same as the position (with the sign).
377
		$significantDigits = $math->getPositionForExponent( $orderOfUncertainty, $this->amount );
378
379
		return $significantDigits;
380
	}
381
382
	/**
383
	 * Returns the unit held by this quantity.
384
	 * Unit-less quantities should use "1" as their unit.
385
	 *
386
	 * @since 0.1
387
	 *
388
	 * @return string
389
	 */
390
	public function getUnit() {
391
		return $this->unit;
392
	}
393
394
	/**
395
	 * Returns a transformed value derived from this QuantityValue by applying
396
	 * the given transformation to the amount and the upper and lower bounds.
397
	 * The resulting amount and bounds are rounded to the significant number of
398
	 * digits. Note that for exact quantities (with at least one bound equal to
399
	 * the amount), no rounding is applied (since they are considered to have
400
	 * infinite precision).
401
	 *
402
	 * The transformation is provided as a callback, which must implement a
403
	 * monotonously increasing, fully differentiable function mapping a DecimalValue
404
	 * to a DecimalValue. Typically, it will be a linear transformation applying a
405
	 * factor and an offset.
406
	 *
407
	 * @param string $newUnit The unit of the transformed quantity.
408
	 *
409
	 * @param callable $transformation A callback that implements the desired transformation.
410
	 *        The transformation will be called three times, once for the amount, once
411
	 *        for the lower bound, and once for the upper bound. It must return a DecimalValue.
412
	 *        The first parameter passed to $transformation is the DecimalValue to transform
413
	 *        In addition, any extra parameters passed to transform() will be passed through
414
	 *        to the transformation callback.
415
	 *
416
	 * @param mixed ... Any extra parameters will be passed to the $transformation function.
417
	 *
418
	 * @throws InvalidArgumentException
419
	 * @return self
420
	 */
421
	public function transform( $newUnit, $transformation ) {
422
		if ( !is_callable( $transformation ) ) {
423
			throw new InvalidArgumentException( '$transformation must be callable.' );
424
		}
425
426
		if ( !is_string( $newUnit ) ) {
427
			throw new InvalidArgumentException( '$newUnit must be a string. Use "1" as the unit for unit-less quantities.' );
428
		}
429
430
		if ( $newUnit === '' ) {
431
			throw new InvalidArgumentException( '$newUnit must not be empty. Use "1" as the unit for unit-less quantities.' );
432
		}
433
434
		$oldUnit = $this->unit;
435
436
		if ( $newUnit === null ) {
437
			$newUnit = $oldUnit;
438
		}
439
440
		// Apply transformation by calling the $transform callback.
441
		// The first argument for the callback is the DataValue to transform. In addition,
442
		// any extra arguments given for transform() are passed through.
443
		$args = func_get_args();
444
		array_shift( $args );
445
446
		$args[0] = $this->amount;
447
		$amount = call_user_func_array( $transformation, $args );
448
449
		$args[0] = $this->upperBound;
450
		$upperBound = call_user_func_array( $transformation, $args );
451
452
		$args[0] = $this->lowerBound;
453
		$lowerBound = call_user_func_array( $transformation, $args );
454
455
		// use a preliminary QuantityValue to determine the significant number of digits
456
		$transformed = new self( $amount, $newUnit, $upperBound, $lowerBound );
457
		$roundingExponent = $transformed->getOrderOfUncertainty();
458
459
		// apply rounding to the significant digits
460
		$math = new DecimalMath();
461
462
		$amount = $math->roundToExponent( $amount, $roundingExponent );
463
		$upperBound = $math->roundToExponent( $upperBound, $roundingExponent );
464
		$lowerBound = $math->roundToExponent( $lowerBound, $roundingExponent );
465
466
		return new self( $amount, $newUnit, $upperBound, $lowerBound );
467
	}
468
469
	public function __toString() {
470
		return $this->amount->getValue()
471
			. ( $this->upperBound
472
				? '[' . $this->lowerBound->getValue() . '..' . $this->upperBound->getValue() . ']'
473
				: '' )
474
			. ( $this->unit === '1' ? '' : $this->unit );
475
	}
476
477
	/**
478
	 * @see DataValue::getArrayValue
479
	 *
480
	 * @since 0.1
481
	 *
482
	 * @return string[]
483
	 */
484
	public function getArrayValue() {
485
		$array = array(
486
			'amount' => $this->amount->getArrayValue(),
487
			'unit' => $this->unit,
488
		);
489
490
		if ( $this->upperBound ) {
491
			$array['upperBound'] = $this->upperBound->getArrayValue();
492
		}
493
		if ( $this->lowerBound ) {
494
			$array['lowerBound'] = $this->lowerBound->getArrayValue();
495
		}
496
497
		return $array;
498
	}
499
500
	/**
501
	 * Constructs a new instance of the DataValue from the provided data.
502
	 * This can round-trip with @see getArrayValue
503
	 *
504
	 * @since 0.1
505
	 *
506
	 * @param mixed $data
507
	 *
508
	 * @return self
509
	 * @throws IllegalValueException
510
	 */
511
	public static function newFromArray( $data ) {
512
		self::requireArrayFields( $data, array( 'amount', 'unit' ) );
513
514
		return new static(
515
			DecimalValue::newFromArray( $data['amount'] ),
516
			$data['unit'],
517
			isset( $data['upperBound'] ) ? DecimalValue::newFromArray( $data['upperBound'] ) : null,
518
			isset( $data['lowerBound'] ) ? DecimalValue::newFromArray( $data['lowerBound'] ) : null
519
		);
520
	}
521
522
	/**
523
	 * @see Comparable::equals
524
	 *
525
	 * @since 0.1
526
	 *
527
	 * @param mixed $target
528
	 *
529
	 * @return bool
530
	 */
531
	public function equals( $target ) {
532
		if ( $this === $target ) {
533
			return true;
534
		}
535
536
		return $target instanceof self
537
			&& $this->toArray() === $target->toArray();
538
	}
539
540
}
541