Test Failed
Push — newFromDecimal ( bb4d99...2fd3da )
by no
03:00
created

QuantityValue::getOrderOfUncertainty()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 22
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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