Completed
Pull Request — master (#55)
by no
10:16 queued 05:05
created

QuantityValue::getOrderOfUncertainty()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 22
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 22
rs 9.2
cc 2
eloc 9
nc 2
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
 * @licence GNU GPL v2+
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 ( $lowerBound->compare( $amount ) > 0 ) {
68
			throw new IllegalValueException( '$lowerBound ' . $lowerBound->getValue() . ' must be <= $amount ' . $amount->getValue() );
0 ignored issues
show
Bug introduced by
It seems like $lowerBound is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
69
		}
70
71
		if ( $upperBound->compare( $amount ) < 0 ) {
72
			throw new IllegalValueException( '$upperBound ' . $upperBound->getValue() . ' must be >= $amount ' . $amount->getValue() );
0 ignored issues
show
Bug introduced by
It seems like $upperBound is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

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