Passed
Push — nullBounds ( 64701e...b3e7e4 )
by no
02:49
created

QuantityValue::asDecimalValue()   B

Complexity

Conditions 6
Paths 4

Size

Total Lines 15
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
c 3
b 0
f 0
dl 0
loc 15
rs 8.8571
cc 6
eloc 9
nc 4
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
 * 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
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...
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
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...
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 ) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
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