Completed
Push — master ( 49a5fb...37c7f4 )
by Daniel
03:09 queued 02:50
created

DecimalMath::bumpDigitsForRounding()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 16
rs 9.4285
cc 2
eloc 8
nc 2
nop 3
1
<?php
2
3
namespace DataValues;
4
5
use InvalidArgumentException;
6
7
/**
8
 * Class for performing basic arithmetic and other transformations
9
 * on DecimalValues.
10
 *
11
 * This uses the bcmath library if available. Otherwise, it falls back on
12
 * using floating point operations.
13
 *
14
 * @note: this is not a genuine decimal arithmetics implementation,
15
 * and should not be used for financial computations, physical simulations, etc.
16
 *
17
 * @see DecimalValue
18
 *
19
 * @since 0.1
20
 *
21
 * @license GPL-2.0+
22
 * @author Daniel Kinzler
23
 */
24
class DecimalMath {
25
26
	/**
27
	 * Whether to use the bcmath library.
28
	 *
29
	 * @var bool
30
	 */
31
	private $useBC;
32
33
	/**
34
	 * @param bool|null $useBC Whether to use the bcmath library. If null,
35
	 *        bcmath will automatically be used if available.
36
	 */
37
	public function __construct( $useBC = null ) {
38
		if ( $useBC === null ) {
39
			$useBC = function_exists( 'bcscale' );
40
		}
41
42
		$this->useBC = $useBC;
43
	}
44
45
	/**
46
	 * Whether this is using the bcmath library.
47
	 *
48
	 * @return bool
49
	 */
50
	public function getUseBC() {
51
		return $this->useBC;
52
	}
53
54
	/**
55
	 * Returns the product of the two values.
56
	 *
57
	 * @param DecimalValue $a
58
	 * @param DecimalValue $b
59
	 *
60
	 * @return DecimalValue
61
	 */
62
	public function product( DecimalValue $a, DecimalValue $b ) {
63
		if ( $this->useBC ) {
64
			$scale = strlen( $a->getFractionalPart() ) + strlen( $b->getFractionalPart() );
65
			$product = bcmul( $a->getValue(), $b->getValue(), $scale );
66
		} else {
67
			$product = $a->getValueFloat() * $b->getValueFloat();
68
		}
69
70
		return new DecimalValue( $product );
71
	}
72
73
	/**
74
	 * Returns the sum of the two values.
75
	 *
76
	 * @param DecimalValue $a
77
	 * @param DecimalValue $b
78
	 *
79
	 * @return DecimalValue
80
	 */
81
	public function sum( DecimalValue $a, DecimalValue $b ) {
82
		if ( $this->useBC ) {
83
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
84
			$sum = bcadd( $a->getValue(), $b->getValue(), $scale );
85
		} else {
86
			$sum = $a->getValueFloat() + $b->getValueFloat();
87
		}
88
89
		return new DecimalValue( $sum );
90
	}
91
92
	/**
93
	 * Returns the minimum of the two values
94
	 *
95
	 * @param DecimalValue $a
96
	 * @param DecimalValue $b
97
	 *
98
	 * @return DecimalValue
99
	 */
100
	public function min( DecimalValue $a, DecimalValue $b ) {
101
102
		if ( $this->useBC ) {
103
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
104
			$comp = bccomp( $a->getValue(), $b->getValue(), $scale );
105
			$min = $comp > 0 ? $b : $a;
106
		} else {
107
			$min = min( $a->getValueFloat(), $b->getValueFloat() );
108
			$min = new DecimalValue( $min );
109
		}
110
111
		return $min;
112
	}
113
114
	/**
115
	 * Returns the maximum of the two values
116
	 *
117
	 * @param DecimalValue $a
118
	 * @param DecimalValue $b
119
	 *
120
	 * @return DecimalValue
121
	 */
122
	public function max( DecimalValue $a, DecimalValue $b ) {
123
124
		if ( $this->useBC ) {
125
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
126
			$comp = bccomp( $a->getValue(), $b->getValue(), $scale );
127
			$max = $comp > 0 ? $a : $b;
128
		} else {
129
			$max = max( $a->getValueFloat(), $b->getValueFloat() );
130
			$max = new DecimalValue( $max );
131
		}
132
133
		return $max;
134
	}
135
136
	/**
137
	 * Returns the given value, with any insignificant digits removed or zeroed.
138
	 *
139
	 * Rounding is applied  using the "round half away from zero" rule (that is, +0.5 is
140
	 * rounded to +1 and -0.5 is rounded to -1).
141
	 *
142
	 * @since 0.1
143
	 *
144
	 * @param DecimalValue $decimal
145
	 * @param int $significantDigits The number of digits to retain, counting the decimal point,
146
	 *        but not counting the leading sign.
147
	 *
148
	 * @throws InvalidArgumentException
149
	 * @return DecimalValue
150
	 */
151
	public function roundToDigit( DecimalValue $decimal, $significantDigits ) {
152
		$value = $decimal->getValue();
153
		$rounded = $this->roundDigits( $value, $significantDigits );
154
		return new DecimalValue( $rounded );
155
	}
156
157
	/**
158
	 * Returns the given value, with any insignificant digits removed or zeroed.
159
	 *
160
	 * Rounding is applied  using the "round half away from zero" rule (that is, +0.5 is
161
	 * rounded to +1 and -0.5 is rounded to -1).
162
	 *
163
	 * @since 0.1
164
	 *
165
	 * @param DecimalValue $decimal
166
	 * @param int $significantExponent 	 The exponent of the last significant digit,
167
	 *        e.g. -1 for "keep the first digit after the decimal point", or 2 for
168
	 *        "zero the last two digits before the decimal point".
169
	 *
170
	 * @throws InvalidArgumentException
171
	 * @return DecimalValue
172
	 */
173
	public function roundToExponent( DecimalValue $decimal, $significantExponent ) {
174
		//NOTE: the number of digits to keep (without the leading sign)
175
		//      is the same as the exponent's offset (with the leaqding sign).
176
		$digits = $this->getPositionForExponent( $significantExponent, $decimal );
177
		return $this->roundToDigit( $decimal, $digits );
178
	}
179
180
	/**
181
	 * Returns the (zero based) position for the given exponent in
182
	 * the given decimal string, counting the decimal point and the leading sign.
183
	 *
184
	 * @example: the position of exponent 0 in "+10.03" is 2.
185
	 * @example: the position of exponent 1 in "+210.03" is 2.
186
	 * @example: the position of exponent -2 in "+1.037" is 4.
187
	 *
188
	 * @param int $exponent
189
	 * @param DecimalValue $decimal
190
	 *
191
	 * @return int
192
	 */
193
	public function getPositionForExponent( $exponent, DecimalValue $decimal ) {
194
		$decimal = $decimal->getValue();
195
196
		$pointPos = strpos( $decimal, '.' );
197
		if ( $pointPos === false ) {
198
			$pointPos = strlen( $decimal );
199
		}
200
201
		// account for leading sign
202
		$pointPos--;
203
204
		if ( $exponent < 0 ) {
205
			// account for decimal point
206
			$position = $pointPos +1 - $exponent;
207
		} else {
208
			// make sure we don't remove more digits than are there
209
			$position = max( 0, $pointPos - $exponent );
210
		}
211
212
		return $position;
213
	}
214
215
	/**
216
	 * Returns the given value, with any insignificant digits removed or zeroed.
217
	 *
218
	 * Rounding is applied using the "round half away from zero" rule (that is, +0.5 is
219
	 * rounded to +1 and -0.5 is rounded to -1).
220
	 *
221
	 * @see round()
222
	 *
223
	 * @param string $value
224
	 * @param int $significantDigits
225
	 *
226
	 * @throws InvalidArgumentException if $significantDigits is smaller than 0
227
	 * @return string
228
	 */
229
	private function roundDigits( $value, $significantDigits ) {
230
		if ( !is_int( $significantDigits ) ) {
231
			throw new InvalidArgumentException( '$significantDigits must be an integer' );
232
		}
233
234
		// keeping no digits results in zero.
235
		if ( $significantDigits === 0 ) {
236
			return '+0';
237
		}
238
239
		if ( $significantDigits < 0 ) {
240
			throw new InvalidArgumentException( '$significantDigits must be larger than zero.' );
241
		}
242
243
		// whether the last character is already part of the integer part of the decimal value
244
		$inIntPart = ( strpos( $value, '.' ) === false );
245
246
		$rounded = '';
247
248
		// Iterate over characters from right to left and build the result back to front.
249
		for ( $i = strlen( $value ) -1; $i > 0 && $i > $significantDigits; $i-- ) {
250
251
			list( $value, $i, $inIntPart, $next ) = $this->roundNextDigit( $value, $i, $inIntPart );
252
253
			$rounded = $next . $rounded;
254
		}
255
256
		// just keep the remainder of the value as is (this includes the sign)
257
		$rounded = substr( $value, 0, $i +1 ) . $rounded;
258
259
		if ( strlen( $rounded ) < $significantDigits + 1 ) {
260
			if ( $inIntPart ) {
261
				$rounded .= '.';
262
			}
263
264
			$rounded = str_pad( $rounded, $significantDigits+1, '0', STR_PAD_RIGHT );
265
		}
266
267
		// strip trailing decimal point
268
		$rounded = rtrim( $rounded, '.' );
269
270
		return $rounded;
271
	}
272
273
	/**
274
	 * Extracts the next character to add to the result of a rounding run:
275
	 * $value[$] will be examined and processed in order to determine the next
276
	 * character to prepend to the result (returned in the $nextCharacter field).
277
	 *
278
	 * Updated values for the parameters are returned as well as the next
279
	 * character.
280
	 *
281
	 * @param string $value
282
	 * @param int $i
283
	 * @param bool $inIntPart
284
	 *
285
	 * @return array ( $value, $i, $inIntPart, $nextCharacter )
286
	 */
287
	private function roundNextDigit( $value, $i, $inIntPart ) {
288
		// next digit
289
		$ch = $value[$i];
290
291
		if ( $ch === '.' ) {
292
			// just transition from the fractional to the integer part
293
			$inIntPart = true;
294
			$nextCharacter = '.';
295
		} else {
296
			if ( $inIntPart ) {
297
				// in the integer part, zero out insignificant digits
298
				$nextCharacter = '0';
299
			} else {
300
				// in the fractional part, strip insignificant digits
301
				$nextCharacter = '';
302
			}
303
304
			if ( ord( $ch ) >= ord( '5' ) ) {
305
				// when stripping a character >= 5, bump up the next digit to the left.
306
				list( $value, $i, $inIntPart ) = $this->bumpDigitsForRounding( $value, $i, $inIntPart );
307
			}
308
		}
309
310
		return array( $value, $i, $inIntPart, $nextCharacter );
311
	}
312
313
	/**
314
	 * Bumps the last digit of a value that is being processed for rounding while taking
315
	 * care of edge cases and updating the state of the rounding process.
316
	 *
317
	 * - $value is truncated to $i digits, so we can safely increment (bump) the last digit.
318
	 * - if the last character of $value is '.', it's trimmed (and $inIntPart is set to true)
319
	 *   to handle the transition from the fractional to the integer part of $value.
320
	 * - the last digit of $value is bumped using bumpDigits() - this is where the magic happens.
321
	 * - $i is set to strln( $value ) to make the index consistent in case a trailing decimal
322
	 *   point got removed.
323
	 *
324
	 * Updated values for the parameters are returned.
325
	 * Note: when returning, $i is always one greater than the greatest valid index in $value.
326
	 *
327
	 * @param string $value
328
	 * @param int $i
329
	 * @param bool $inIntPart
330
	 *
331
	 * @return array ( $value, $i, $inIntPart, $next )
332
	 */
333
	private function bumpDigitsForRounding( $value, $i, $inIntPart ) {
334
		$remaining = substr( $value, 0, $i );
335
336
		// If there's a '.' at the end, strip it and note that we are in the
337
		// integer part of $value now.
338
		if ( $remaining[ strlen( $remaining ) -1 ] === '.' ) {
339
			$remaining = rtrim( $remaining, '.' );
340
			$inIntPart = true;
341
		}
342
343
		// Rounding may add digits, adjust $i for that.
344
		$value = $this->bumpDigits( $remaining );
345
		$i = strlen( $value );
346
347
		return array( $value, $i, $inIntPart );
348
	}
349
350
	/**
351
	 * Increment the least significant digit by one if it is less than 9, and
352
	 * set it to zero and continue to the next more significant digit if it is 9.
353
	 * Exception: bump( 0 ) == 1;
354
	 *
355
	 * E.g.: bump( 0.2 ) == 0.3, bump( -0.09 ) == -0.10, bump( 9.99 ) == 10.00
356
	 *
357
	 * This is the inverse of @see slump()
358
	 *
359
	 * @since 0.1
360
	 *
361
	 * @param DecimalValue $decimal
362
	 *
363
	 * @return DecimalValue
364
	 */
365
	public function bump( DecimalValue $decimal ) {
366
		$value = $decimal->getValue();
367
		$bumped = $this->bumpDigits( $value );
368
		return new DecimalValue( $bumped );
369
	}
370
371
	/**
372
	 * Increment the least significant digit by one if it is less than 9, and
373
	 * set it to zero and continue to the next more significant digit if it is 9.
374
	 *
375
	 * @see bump()
376
	 *
377
	 * @param string $value
378
	 * @return string
379
	 */
380
	private function bumpDigits( $value ) {
381
		if ( $value === '+0' ) {
382
			return '+1';
383
		}
384
385
		$bumped = '';
386
387
		for ( $i = strlen( $value ) -1; $i >= 0; $i-- ) {
388
			$ch = $value[$i];
389
390
			if ( $ch === '.' ) {
391
				$bumped = '.' . $bumped;
392
				continue;
393
			} elseif ( $ch === '9' ) {
394
				$bumped = '0' . $bumped;
395
				continue;
396
			} elseif ( $ch === '+' || $ch === '-' ) {
397
				$bumped = $ch . '1' . $bumped;
398
				break;
399
			} else {
400
				$bumped =  chr( ord( $ch ) + 1 ) . $bumped;
401
				break;
402
			}
403
		}
404
405
		$bumped = substr( $value, 0, $i ) . $bumped;
406
		return $bumped;
407
	}
408
409
	/**
410
	 * Decrement the least significant digit by one if it is more than 0, and
411
	 * set it to 9 and continue to the next more significant digit if it is 0.
412
	 * Exception: slump( 0 ) == -1;
413
	 *
414
	 * E.g.: slump( 0.2 ) == 0.1, slump( -0.10 ) == -0.01, slump( 0.0 ) == -1.0
415
	 *
416
	 * This is the inverse of @see bump()
417
	 *
418
	 * @since 0.1
419
	 *
420
	 * @param DecimalValue $decimal
421
	 *
422
	 * @return DecimalValue
423
	 */
424
	public function slump( DecimalValue $decimal ) {
425
		$value = $decimal->getValue();
426
		$slumped = $this->slumpDigits( $value );
427
		return new DecimalValue( $slumped );
428
	}
429
430
	/**
431
	 * Decrement the least significant digit by one if it is more than 0, and
432
	 * set it to 9 and continue to the next more significant digit if it is 0.
433
	 *
434
	 * @see slump()
435
	 *
436
	 * @param string $value
437
	 * @return string
438
	 */
439
	private function slumpDigits( $value ) {
440
		if ( $value === '+0' ) {
441
			return '-1';
442
		}
443
444
		// a "precise zero" will become negative
445
		if ( preg_match( '/^\+0\.(0*)0$/', $value, $m ) ) {
446
			return '-0.' . $m[1] . '1';
447
		}
448
449
		$slumped = '';
450
451
		for ( $i = strlen( $value ) -1; $i >= 0; $i-- ) {
452
			$ch = substr( $value, $i, 1 );
453
454
			if ( $ch === '.' ) {
455
				$slumped = '.' . $slumped;
456
				continue;
457
			} elseif ( $ch === '0' ) {
458
				$slumped = '9' . $slumped;
459
				continue;
460
			} elseif ( $ch === '+' || $ch === '-' ) {
461
				$slumped = '0';
462
				break;
463
			} else {
464
				$slumped =  chr( ord( $ch ) - 1 ) . $slumped;
465
				break;
466
			}
467
		}
468
469
		// preserve prefix
470
		$slumped = substr( $value, 0, $i ) . $slumped;
471
472
		$slumped = $this->stripLeadingZeros( $slumped );
473
474
		if ( $slumped === '-0' ) {
475
			$slumped = '+0';
476
		}
477
478
		return $slumped;
479
	}
480
481
	/**
482
	 * @param string $digits
483
	 *
484
	 * @return string
485
	 */
486
	private function stripLeadingZeros( $digits ) {
487
		$digits = preg_replace( '/^([-+])0+(?=\d)/', '\1', $digits );
488
		return $digits;
489
	}
490
491
	/**
492
	 * Shift the decimal point according to the given exponent.
493
	 *
494
	 * @param DecimalValue $decimal
495
	 * @param int $exponent The exponent to apply (digits to shift by). A Positive exponent
496
	 * shifts the decimal point to the right, a negative exponent shifts to the left.
497
	 *
498
	 * @throws InvalidArgumentException
499
	 * @return DecimalValue
500
	 */
501
	public function shift( DecimalValue $decimal, $exponent ) {
502
		if ( !is_int( $exponent ) ) {
503
			throw new InvalidArgumentException( '$exponent must be an integer' );
504
		}
505
506
		if ( $exponent == 0 ) {
507
			return $decimal;
508
		}
509
510
		$sign = $decimal->getSign();
511
		$intPart = $decimal->getIntegerPart();
512
		$fractPart = $decimal->getFractionalPart();
513
514
		if ( $exponent < 0 ) {
515
			$intPart = $this->shiftLeft( $intPart, $exponent );
516
		} else {
517
			$fractPart = $this->shiftRight( $fractPart, $exponent );
518
		}
519
520
		$digits = $sign . $intPart . $fractPart;
521
		$digits = $this->stripLeadingZeros( $digits );
522
523
		return new DecimalValue( $digits );
524
	}
525
526
	/**
527
	 * @param string $intPart
528
	 * @param int $exponent must be negative
529
	 *
530
	 * @return string
531
	 */
532
	private function shiftLeft( $intPart, $exponent ) {
533
		//note: $exponent is negative!
534
		if ( -$exponent < strlen( $intPart ) ) {
535
			$intPart = substr( $intPart, 0, $exponent ) . '.' . substr( $intPart, $exponent );
536
		} else {
537
			$intPart = '0.' . str_pad( $intPart, -$exponent, '0', STR_PAD_LEFT );
538
		}
539
540
		return $intPart;
541
	}
542
543
	/**
544
	 * @param string $fractPart
545
	 * @param int $exponent must be positive
546
	 *
547
	 * @return string
548
	 */
549
	private function shiftRight( $fractPart, $exponent ) {
550
		//note: $exponent is positive.
551
		if ( $exponent < strlen( $fractPart ) ) {
552
			$fractPart = substr( $fractPart, 0, $exponent ) . '.' . substr( $fractPart, $exponent );
553
		} else {
554
			$fractPart = str_pad( $fractPart, $exponent, '0', STR_PAD_RIGHT );
555
		}
556
557
		return $fractPart;
558
	}
559
560
}
561