Passed
Push — nullBounds ( e7cb1a...08f97f )
by no
07:32 queued 04:25
created

QuantityValue::__toString()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 7
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
c 3
b 0
f 0
dl 0
loc 7
rs 9.4285
cc 3
eloc 6
nc 4
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
		// the number of digits (without the sign) is the same as the position (with the sign).
382
		$significantDigits = $math->getPositionForExponent( $orderOfUncertainty, $this->amount );
383
384
		return $significantDigits;
385
	}
386
387
	/**
388
	 * Returns the unit held by this quantity.
389
	 * Unit-less quantities should use "1" as their unit.
390
	 *
391
	 * @since 0.1
392
	 *
393
	 * @return string
394
	 */
395
	public function getUnit() {
396
		return $this->unit;
397
	}
398
399
	/**
400
	 * Returns a transformed value derived from this QuantityValue by applying
401
	 * the given transformation to the amount and the upper and lower bounds.
402
	 * The resulting amount and bounds are rounded to the significant number of
403
	 * digits. Note that for exact quantities (with at least one bound equal to
404
	 * the amount), no rounding is applied (since they are considered to have
405
	 * infinite precision).
406
	 *
407
	 * The transformation is provided as a callback, which must implement a
408
	 * monotonously increasing, fully differentiable function mapping a DecimalValue
409
	 * to a DecimalValue. Typically, it will be a linear transformation applying a
410
	 * factor and an offset.
411
	 *
412
	 * @param string $newUnit The unit of the transformed quantity.
413
	 *
414
	 * @param callable $transformation A callback that implements the desired transformation.
415
	 *        The transformation will be called three times, once for the amount, once
416
	 *        for the lower bound, and once for the upper bound. It must return a DecimalValue.
417
	 *        The first parameter passed to $transformation is the DecimalValue to transform
418
	 *        In addition, any extra parameters passed to transform() will be passed through
419
	 *        to the transformation callback.
420
	 *
421
	 * @param mixed ... Any extra parameters will be passed to the $transformation function.
422
	 *
423
	 * @throws InvalidArgumentException
424
	 * @return self
425
	 */
426
	public function transform( $newUnit, $transformation ) {
427
		if ( !is_callable( $transformation ) ) {
428
			throw new InvalidArgumentException( '$transformation must be callable.' );
429
		}
430
431
		if ( !is_string( $newUnit ) ) {
432
			throw new InvalidArgumentException( '$newUnit must be a string. Use "1" as the unit for unit-less quantities.' );
433
		}
434
435
		if ( $newUnit === '' ) {
436
			throw new InvalidArgumentException( '$newUnit must not be empty. Use "1" as the unit for unit-less quantities.' );
437
		}
438
439
		$oldUnit = $this->unit;
440
441
		if ( $newUnit === null ) {
442
			$newUnit = $oldUnit;
443
		}
444
445
		// Apply transformation by calling the $transform callback.
446
		// The first argument for the callback is the DataValue to transform. In addition,
447
		// any extra arguments given for transform() are passed through.
448
		$args = func_get_args();
449
		array_shift( $args );
450
451
		$args[0] = $this->amount;
452
		$amount = call_user_func_array( $transformation, $args );
453
454
		$upperBound = null;
455
		$lowerBound = null;
456
457
		if ( $this->hasBounds() ) {
458
			$args[0] = $this->upperBound;
459
			$upperBound = call_user_func_array( $transformation, $args );
460
461
			$args[0] = $this->lowerBound;
462
			$lowerBound = call_user_func_array( $transformation, $args );
463
		}
464
465
		// use a preliminary QuantityValue to determine the significant number of digits
466
		$transformed = new self( $amount, $newUnit, $upperBound, $lowerBound );
467
		$roundingExponent = $transformed->getOrderOfUncertainty();
468
469
		// apply rounding to the significant digits
470
		$math = new DecimalMath();
471
472
		$amount = $math->roundToExponent( $amount, $roundingExponent );
473
474
		if ( $this->hasBounds() ) {
475
			$upperBound = $math->roundToExponent( $upperBound, $roundingExponent );
476
			$lowerBound = $math->roundToExponent( $lowerBound, $roundingExponent );
477
		}
478
479
		return new self( $amount, $newUnit, $upperBound, $lowerBound );
480
	}
481
482
	public function __toString() {
483
		return $this->amount->getValue()
484
			. ( $this->hasBounds()
485
				? '[' . $this->lowerBound->getValue() . '..' . $this->upperBound->getValue() . ']'
486
				: '' )
487
			. ( $this->unit === '1' ? '' : $this->unit );
488
	}
489
490
	/**
491
	 * @see DataValue::getArrayValue
492
	 *
493
	 * @since 0.1
494
	 *
495
	 * @return string[]
496
	 */
497
	public function getArrayValue() {
498
		$array = array(
499
			'amount' => $this->amount->getArrayValue(),
500
			'unit' => $this->unit,
501
		);
502
503
		if ( $this->hasBounds() ) {
504
			$array['upperBound'] = $this->upperBound->getArrayValue();
505
			$array['lowerBound'] = $this->lowerBound->getArrayValue();
506
		}
507
508
		return $array;
509
	}
510
511
	/**
512
	 * Constructs a new instance of the DataValue from the provided data.
513
	 * This can round-trip with @see getArrayValue
514
	 *
515
	 * @since 0.1
516
	 *
517
	 * @param mixed $data
518
	 *
519
	 * @return self
520
	 * @throws IllegalValueException
521
	 */
522
	public static function newFromArray( $data ) {
523
		self::requireArrayFields( $data, array( 'amount', 'unit' ) );
524
525
		return new static(
526
			DecimalValue::newFromArray( $data['amount'] ),
527
			$data['unit'],
528
			isset( $data['upperBound'] ) ? DecimalValue::newFromArray( $data['upperBound'] ) : null,
529
			isset( $data['lowerBound'] ) ? DecimalValue::newFromArray( $data['lowerBound'] ) : null
530
		);
531
	}
532
533
	/**
534
	 * @see Comparable::equals
535
	 *
536
	 * @since 0.1
537
	 *
538
	 * @param mixed $target
539
	 *
540
	 * @return bool
541
	 */
542
	public function equals( $target ) {
543
		if ( $this === $target ) {
544
			return true;
545
		}
546
547
		return $target instanceof self
548
			&& $this->toArray() === $target->toArray();
549
	}
550
551
}
552