Passed
Push — nullBounds ( f0b4c5...e7cb1a )
by no
05:06
created

QuantityValue::getOrderOfUncertainty()   B

Complexity

Conditions 3
Paths 3

Size

Total Lines 26
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
c 3
b 0
f 0
dl 0
loc 26
rs 8.8571
cc 3
eloc 11
nc 3
nop 0
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
	 * @since 0.8
248
	 *
249
	 * @return bool
250
	 */
251
	public function hasBounds() {
252
		return $this->upperBound !== null;
253
	}
254
255
	/**
256
	 * Returns this quantity's upper bound.
257
	 *
258
	 * @since 0.1
259
	 *
260
	 * @return DecimalValue|null
261
	 */
262
	public function getUpperBound() {
263
		return $this->upperBound;
264
	}
265
266
	/**
267
	 * Returns this quantity's lower bound.
268
	 *
269
	 * @since 0.1
270
	 *
271
	 * @return DecimalValue|null
272
	 */
273
	public function getLowerBound() {
274
		return $this->lowerBound;
275
	}
276
277
	/**
278
	 * Returns the size of the uncertainty interval.
279
	 * This can roughly be interpreted as "amount +/- uncertainty/2".
280
	 *
281
	 * The exact interpretation of the uncertainty interval is left to the concrete application or
282
	 * data point. For example, the uncertainty interval may be defined to be that part of a
283
	 * normal distribution that is required to cover the 95th percentile.
284
	 *
285
	 * @since 0.1
286
	 *
287
	 * @return float|null
288
	 */
289
	public function getUncertainty() {
290
		return $this->hasBounds()
291
			? $this->upperBound->getValueFloat() - $this->lowerBound->getValueFloat()
292
			: null;
293
	}
294
295
	/**
296
	 * Returns a DecimalValue representing the symmetrical offset to be applied
297
	 * to the raw amount for a rough representation of the uncertainty interval,
298
	 * as in "amount +/- offset".
299
	 *
300
	 * The offset is calculated as max( amount - lowerBound, upperBound - amount ).
301
	 *
302
	 * @since 0.1
303
	 *
304
	 * @return DecimalValue|null
305
	 */
306
	public function getUncertaintyMargin() {
307
		if ( !$this->hasBounds() ) {
308
			return null;
309
		}
310
311
		$math = new DecimalMath();
312
313
		$lowerMargin = $math->sum( $this->amount, $this->lowerBound->computeComplement() );
314
		$upperMargin = $math->sum( $this->upperBound, $this->amount->computeComplement() );
0 ignored issues
show
Bug introduced by
It seems like $this->upperBound can be null; however, sum() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
315
316
		$margin = $math->max( $lowerMargin, $upperMargin );
317
		return $margin;
318
	}
319
320
	/**
321
	 * Returns the order of magnitude of the uncertainty as the exponent of
322
	 * last significant digit in the amount-string. The value returned by this
323
	 * is suitable for use with @see DecimalMath::roundToExponent().
324
	 *
325
	 * @example: if two digits after the decimal point are significant, this
326
	 * returns -2.
327
	 *
328
	 * @example: if the last two digits before the decimal point are insignificant,
329
	 * this returns 2.
330
	 *
331
	 * Note that this calculation assumes a symmetric uncertainty interval,
332
	 * and can be misleading.
333
	 *
334
	 * @since 0.1
335
	 *
336
	 * @return int|null
337
	 */
338
	public function getOrderOfUncertainty() {
339
		if ( !$this->hasBounds() ) {
340
			return null;
341
		}
342
343
		// the desired precision is given by the distance between the amount and
344
		// whatever is closer, the upper or lower bound.
345
		//TODO: use DecimalMath to avoid floating point errors!
346
		$amount = $this->amount->getValueFloat();
347
		$upperBound = $this->upperBound->getValueFloat();
348
		$lowerBound = $this->lowerBound->getValueFloat();
349
		$precision = min( $amount - $lowerBound, $upperBound - $amount );
350
351
		if ( $precision === 0.0 ) {
352
			// If there is no uncertainty, the order of uncertainty is a bit more than what we have digits for.
353
			return -strlen( $this->amount->getFractionalPart() );
354
		}
355
356
		// e.g. +/- 200 -> 2; +/- 0.02 -> -2
357
		// note: we really want floor( log10( $precision ) ), but have to account for
358
		// small errors made in the floating point operations above.
359
		// @todo: use bcmath (via DecimalMath) to avoid this if possible
360
		$orderOfUncertainty = floor( log10( $precision + 0.0000000005 ) );
361
362
		return (int)$orderOfUncertainty;
363
	}
364
365
	/**
366
	 * Returns the number of significant figures in the amount-string,
367
	 * counting the decimal point, but not counting the leading sign.
368
	 *
369
	 * Note that this calculation assumes a symmetric uncertainty interval, and can be misleading
370
	 *
371
	 * @since 0.1
372
	 *
373
	 * @return int|null
374
	 */
375
	public function getSignificantFigures() {
376
		$math = new DecimalMath();
377
378
		// $orderOfUncertainty is +/- 200 -> 2; +/- 0.02 -> -2
379
		$orderOfUncertainty = $this->getOrderOfUncertainty();
380
381
		if ( $orderOfUncertainty === null ) {
382
			return null;
383
		}
384
385
		// the number of digits (without the sign) is the same as the position (with the sign).
386
		$significantDigits = $math->getPositionForExponent( $orderOfUncertainty, $this->amount );
387
388
		return $significantDigits;
389
	}
390
391
	/**
392
	 * Returns the unit held by this quantity.
393
	 * Unit-less quantities should use "1" as their unit.
394
	 *
395
	 * @since 0.1
396
	 *
397
	 * @return string
398
	 */
399
	public function getUnit() {
400
		return $this->unit;
401
	}
402
403
	/**
404
	 * Returns a transformed value derived from this QuantityValue by applying
405
	 * the given transformation to the amount and the upper and lower bounds.
406
	 * The resulting amount and bounds are rounded to the significant number of
407
	 * digits. Note that for exact quantities (with at least one bound equal to
408
	 * the amount), no rounding is applied (since they are considered to have
409
	 * infinite precision).
410
	 *
411
	 * The transformation is provided as a callback, which must implement a
412
	 * monotonously increasing, fully differentiable function mapping a DecimalValue
413
	 * to a DecimalValue. Typically, it will be a linear transformation applying a
414
	 * factor and an offset.
415
	 *
416
	 * @param string $newUnit The unit of the transformed quantity.
417
	 *
418
	 * @param callable $transformation A callback that implements the desired transformation.
419
	 *        The transformation will be called three times, once for the amount, once
420
	 *        for the lower bound, and once for the upper bound. It must return a DecimalValue.
421
	 *        The first parameter passed to $transformation is the DecimalValue to transform
422
	 *        In addition, any extra parameters passed to transform() will be passed through
423
	 *        to the transformation callback.
424
	 *
425
	 * @param mixed ... Any extra parameters will be passed to the $transformation function.
426
	 *
427
	 * @throws InvalidArgumentException
428
	 * @return self
429
	 */
430
	public function transform( $newUnit, $transformation ) {
431
		if ( !is_callable( $transformation ) ) {
432
			throw new InvalidArgumentException( '$transformation must be callable.' );
433
		}
434
435
		if ( !is_string( $newUnit ) ) {
436
			throw new InvalidArgumentException( '$newUnit must be a string. Use "1" as the unit for unit-less quantities.' );
437
		}
438
439
		if ( $newUnit === '' ) {
440
			throw new InvalidArgumentException( '$newUnit must not be empty. Use "1" as the unit for unit-less quantities.' );
441
		}
442
443
		$oldUnit = $this->unit;
444
445
		if ( $newUnit === null ) {
446
			$newUnit = $oldUnit;
447
		}
448
449
		// Apply transformation by calling the $transform callback.
450
		// The first argument for the callback is the DataValue to transform. In addition,
451
		// any extra arguments given for transform() are passed through.
452
		$args = func_get_args();
453
		array_shift( $args );
454
455
		$args[0] = $this->amount;
456
		$amount = call_user_func_array( $transformation, $args );
457
458
		$upperBound = null;
459
		$lowerBound = null;
460
461
		if ( $this->hasBounds() ) {
462
			$args[0] = $this->upperBound;
463
			$upperBound = call_user_func_array( $transformation, $args );
464
465
			$args[0] = $this->lowerBound;
466
			$lowerBound = call_user_func_array( $transformation, $args );
467
		}
468
469
		// use a preliminary QuantityValue to determine the significant number of digits
470
		$transformed = new self( $amount, $newUnit, $upperBound, $lowerBound );
471
		$roundingExponent = $transformed->getOrderOfUncertainty();
472
473
		// apply rounding to the significant digits
474
		$math = new DecimalMath();
475
476
		$amount = $math->roundToExponent( $amount, $roundingExponent );
477
478
		if ( $this->hasBounds() ) {
479
			$upperBound = $math->roundToExponent( $upperBound, $roundingExponent );
480
			$lowerBound = $math->roundToExponent( $lowerBound, $roundingExponent );
481
		}
482
483
		return new self( $amount, $newUnit, $upperBound, $lowerBound );
484
	}
485
486
	public function __toString() {
487
		return $this->amount->getValue()
488
			. ( $this->hasBounds()
489
				? '[' . $this->lowerBound->getValue() . '..' . $this->upperBound->getValue() . ']'
490
				: '' )
491
			. ( $this->unit === '1' ? '' : $this->unit );
492
	}
493
494
	/**
495
	 * @see DataValue::getArrayValue
496
	 *
497
	 * @since 0.1
498
	 *
499
	 * @return string[]
500
	 */
501
	public function getArrayValue() {
502
		$array = array(
503
			'amount' => $this->amount->getArrayValue(),
504
			'unit' => $this->unit,
505
		);
506
507
		if ( $this->hasBounds() ) {
508
			$array['upperBound'] = $this->upperBound->getArrayValue();
509
			$array['lowerBound'] = $this->lowerBound->getArrayValue();
510
		}
511
512
		return $array;
513
	}
514
515
	/**
516
	 * Constructs a new instance of the DataValue from the provided data.
517
	 * This can round-trip with @see getArrayValue
518
	 *
519
	 * @since 0.1
520
	 *
521
	 * @param mixed $data
522
	 *
523
	 * @return self
524
	 * @throws IllegalValueException
525
	 */
526
	public static function newFromArray( $data ) {
527
		self::requireArrayFields( $data, array( 'amount', 'unit' ) );
528
529
		return new static(
530
			DecimalValue::newFromArray( $data['amount'] ),
531
			$data['unit'],
532
			isset( $data['upperBound'] ) ? DecimalValue::newFromArray( $data['upperBound'] ) : null,
533
			isset( $data['lowerBound'] ) ? DecimalValue::newFromArray( $data['lowerBound'] ) : null
534
		);
535
	}
536
537
	/**
538
	 * @see Comparable::equals
539
	 *
540
	 * @since 0.1
541
	 *
542
	 * @param mixed $target
543
	 *
544
	 * @return bool
545
	 */
546
	public function equals( $target ) {
547
		if ( $this === $target ) {
548
			return true;
549
		}
550
551
		return $target instanceof self
552
			&& $this->toArray() === $target->toArray();
553
	}
554
555
}
556