Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 15 Deb 2016 */ namespace IPS\Math; /* To prevent PHP errors (extending class does not exist) revealing path */ use InvalidArgumentException; use JsonSerializable; use RuntimeException; use function defined; use function extension_loaded; use function intval; use function is_string; use function substr; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Number Class for precise math */ class Number implements JsonSerializable { /* !Bootstrap */ /** * @brief Positive */ protected bool $positive = TRUE; /** * @brief Number before decimal point */ protected int $beforeDecimalPoint = 0; /** * @brief Number of decimal places */ protected int $numberOfDecimalPlaces = 0; /** * @brief After decimal point */ protected int $afterDecimalPoint = 0; /** * Constructor * * @param string $number The number, as a string, using "." for decimal points * @return void * @throws InvalidArgumentException */ public function __construct( string $number ) { /* Check it's valid */ if ( !is_string( $number ) ) { throw new InvalidArgumentException('NOT_A_STRING'); } if ( $number === '' ) { throw new InvalidArgumentException('NOT_VALID_NUMBER'); } if ( !preg_match( '/^([+-])?(\d*)(\.(\d*))?$/', $number, $matches ) ) { throw new InvalidArgumentException('NOT_VALID_NUMBER'); } /* Set properties */ if ( isset( $matches[1] ) and $matches[1] === '-' ) { $this->positive = FALSE; } $this->beforeDecimalPoint = isset( $matches[2] ) ? intval( $matches[2] ) : 0; $this->numberOfDecimalPlaces = isset( $matches[4] ) ? mb_strlen( $matches[4] ) : 0; $this->afterDecimalPoint = isset( $matches[4] ) ? intval( $matches[4] ) : 0; /* If we have x.y00, simplify to x.y */ $this->_simplifyDecimalPlaces(); } /** * Number is positive? * * @return bool */ public function isPositive(): bool { return $this->positive; } /** * Number is zero? * * @return bool */ public function isZero(): bool { return ( !$this->beforeDecimalPoint and !$this->afterDecimalPoint ); } /** * Number is greater than zero? * * @return bool */ public function isGreaterThanZero(): bool { return $this->isPositive() and ( $this->beforeDecimalPoint or $this->afterDecimalPoint ); } /* !Basic Math */ /** * Add * * @param Number $number Number to add * @return Number */ public function add( Number $number ): Number { /* This method handles adding two positive numbers, if either (or both) are negative, pass off to subtract() as appropriate */ if ( !$this->positive ) { if ( !$number->positive ) { /* Both numbers are negative, return -(abs($x)+abs($y)) */ return $this->multiply( new Number( '-1' ) )->add( $number->multiply( new Number( '-1' ) ) )->multiply( new Number( '-1' ) ); } else { /* This number is negative, but the number we're adding is positive, return $y-abs($x) */ return $number->subtract( $this->multiply( new Number( '-1' ) ) ); } } elseif ( !$number->positive ) { /* This number is positive, but the number we're adding is negative, return $x-abs($y) */ return $this->subtract( $number->multiply( new Number( '-1' ) ) ); } /* Work out which number has more numbers are the decimal */ $biggerDecimalPlaces = max( array( $this->numberOfDecimalPlaces, $number->numberOfDecimalPlaces ) ); /* If these are two integers, just do it natively */ if ( !$biggerDecimalPlaces ) { return new Number( (string) ( $this->beforeDecimalPoint + $number->beforeDecimalPoint ) ); } /* Otherwise, add the two absolute values together */ $paddedThis = intval( "{$this->beforeDecimalPoint}" . str_pad( str_pad( $this->afterDecimalPoint, $this->numberOfDecimalPlaces, '0', STR_PAD_LEFT ), $biggerDecimalPlaces, '0' ) ); $paddedNumber = intval( "{$number->beforeDecimalPoint}" . str_pad( str_pad( $number->afterDecimalPoint, $number->numberOfDecimalPlaces, '0', STR_PAD_LEFT ), $biggerDecimalPlaces, '0' ) ); $absoluteResult = $paddedThis + $paddedNumber; /* ... and then add the decimal back in the correct place */ $return = new Number( mb_substr( $absoluteResult, 0, -$biggerDecimalPlaces ) . '.' . str_pad( mb_substr( $absoluteResult, -$biggerDecimalPlaces ), $biggerDecimalPlaces, '0', STR_PAD_LEFT ) ); /* Simplify and return */ $return->_simplifyDecimalPlaces(); return $return; } /** * Subtract * * @param Number $number Number to subtract * @return Number */ public function subtract( Number $number ): Number { /* This method handles subtracting two positive numbers, if either (or both) are negative, pass off to add() as appropriate */ if ( !$this->positive ) { if ( !$number->positive ) { /* Both numbers are negative, return abs(abs($x)-abs($y)) */ return $this->multiply( new Number( '-1' ) )->subtract( $number->multiply( new Number( '-1' ) ) )->multiply( new Number( '-1' ) ); } else { /* This number is negative, but the number we're adding is positive, return -$y+abs($x) */ return $number->add( $this->multiply( new Number( '-1' ) ) )->multiply( new Number( '-1' ) ); } } elseif ( !$number->positive ) { /* This number is positive, but the number we're adding is negative, return $x+abs($y) */ return $this->add( $number->multiply( new Number( '-1' ) ) ); } /* Work out which number has more numbers are the decimal */ $biggerDecimalPlaces = max( array( $this->numberOfDecimalPlaces, $number->numberOfDecimalPlaces ) ); /* If these are two integers, just do it natively */ if ( !$biggerDecimalPlaces ) { return new Number( (string) ( $this->beforeDecimalPoint - $number->beforeDecimalPoint ) ); } /* Otherwise, add the two absolute values together */ $paddedThis = intval( "{$this->beforeDecimalPoint}" . str_pad( str_pad( $this->afterDecimalPoint, $this->numberOfDecimalPlaces, '0', STR_PAD_LEFT ), $biggerDecimalPlaces, '0' ) ); $paddedNumber = intval( "{$number->beforeDecimalPoint}" . str_pad( str_pad( $number->afterDecimalPoint, $number->numberOfDecimalPlaces, '0', STR_PAD_LEFT ), $biggerDecimalPlaces, '0' ) ); $absoluteResult = $paddedThis - $paddedNumber; /* ... and then add the decimal back in the correct place */ if ( $absoluteResult < 0 ) { $absoluteResult *= -1; $return = new Number( '-' . mb_substr( $absoluteResult, 0, -$biggerDecimalPlaces ) . '.' . str_pad( mb_substr( $absoluteResult, -$biggerDecimalPlaces ), $biggerDecimalPlaces, '0', STR_PAD_LEFT ) ); } else { $return = new Number( mb_substr( $absoluteResult, 0, -$biggerDecimalPlaces ) . '.' . str_pad( mb_substr( $absoluteResult, -$biggerDecimalPlaces ), $biggerDecimalPlaces, '0', STR_PAD_LEFT ) ); } /* Simplify and return */ $return->_simplifyDecimalPlaces(); return $return; } /** * Multiple * * @param Number $number Number to multiply by * @return Number */ public function multiply( Number $number ): Number { /* Multiply the absolute numbers */ $absoluteResult = intval( "{$this->beforeDecimalPoint}" . ( $this->afterDecimalPoint ? str_pad( $this->afterDecimalPoint, $this->numberOfDecimalPlaces, '0', STR_PAD_LEFT ) : '' ) ) * intval( "{$number->beforeDecimalPoint}" . ( $number->afterDecimalPoint ? str_pad( $number->afterDecimalPoint, $number->numberOfDecimalPlaces, '0', STR_PAD_LEFT ) : '' ) ); /* Work out where the decimal point goes */ $numberOfDecimalPlaces = $this->numberOfDecimalPlaces + $number->numberOfDecimalPlaces; $absoluteResult = str_pad( $absoluteResult, $numberOfDecimalPlaces, '0', STR_PAD_LEFT ); $result = $numberOfDecimalPlaces ? ( mb_substr( $absoluteResult, 0, -$numberOfDecimalPlaces ) . '.' . mb_substr( $absoluteResult, mb_strlen( $absoluteResult ) - $numberOfDecimalPlaces ) ) : "{$absoluteResult}"; /* Work out if the result is positive or negative */ $positiveOrNegative = ''; if ( $this->positive xor $number->positive ) { $positiveOrNegative = '-'; } /* Return */ return new Number( "{$positiveOrNegative}{$result}" ); } /** * @brief Truncates the result and does not round */ const ROUND_TRUNCATE = 0; /** * @brief Uses normal rules for rounding (>=0.5 rounds up, <0.5 rounds down) */ const ROUND_NORMAL = 1; /** * @brief Rounds any decimal up */ const ROUND_UP = 2; /** * Divide * * @param Number $number Number to divide by * @param int $precision The precision to work to * @param int|NULL $round Handles how to round the result. See ROUND_* constants * @return Number * @throws RuntimeException */ public function divide( Number $number, int $precision = 3, ?int $round = Number::ROUND_TRUNCATE ): Number { /* Can't divide by 0 */ if ( $number->compare( new static('0') ) === 0 ) { throw new RuntimeException('DIVIDE_BY_ZERO'); } /* Divide by 1 does nothing */ if ( $number->compare( new static('1') ) === 0 ) { return clone $this; } /* If we want to round, go up a precision */ if ( $round !== static::ROUND_TRUNCATE ) { $precision++; } /* If the bcmath extension is available, we can use bcdiv to do this effectively */ if ( extension_loaded('bcmath') ) { $result = str_replace( '.', '', bcdiv( (string) $this->absolute(), (string) $number->absolute(), $precision ) ); // It will automatically return with correct $precision - e.g. "1.000" rather than "1", so we can just strip the decimal to get the absolute value we need } /* Otherwise we have to work out the remainder from ( $x * ( 10 ^ $precision ) / $number ) */ else { $result = 0; $this->absolute()->multiply( new Number( (string) pow( 10, $precision ) ) )->modulus( $number->absolute(), $result ); } /* Round it */ if ( $round !== static::ROUND_TRUNCATE ) { $lastDigit = intval( mb_substr( $result, -1 ) ); $result = mb_substr( $result, 0, -1 ); if ( $lastDigit !== 0 ) { if ( $round === static::ROUND_NORMAL ) { if ( $lastDigit >= 5 ) { $result++; } else { $result--; } } elseif ( $round === static::ROUND_UP ) { $result++; } } $precision--; } /* Work out if the result is positive or negative */ $positiveOrNegative = ''; if ( $this->positive xor $number->positive ) { $positiveOrNegative = '-'; } /* Put the decimal back in the correct place */ $result = str_pad( $result, $precision, '0', STR_PAD_LEFT ); $return = new Number( $positiveOrNegative . mb_substr( $result, 0, -$precision ) . '.' . mb_substr( $result, -$precision ) ); /* Simplify and return */ $return->_simplifyDecimalPlaces(); return $return; } /** * Modulus * * @param Number $number Number to get modulus of * @param int $divides Dividend * @return Number */ public function modulus( Number $number, int &$divides = 0 ): Number { /* Start with positiver numbers */ $remainder = clone $this; if ( !$remainder->positive ) { $remainder = $remainder->multiply( new Number('-1') ); } $negative = FALSE; if ( !$number->positive ) { $negative = TRUE; $number = $number->multiply( new Number('-1') ); } /* While the number is greater than this number... */ while ( $remainder->compare( $number ) === 1 ) { /* Add one to the amount we can divide by */ $divides++; /* And then subtract the number from what's remaining */ $remainder = $remainder->subtract( $number ); } /* If the number is now equal to this number, add one more one and specify the remainder as 0 */ if ( $remainder->compare( $number ) === 0 ) { $divides++; $remainder = new Number('0'); } /* Return */ return $negative ? $remainder->multiply( new Number('-1') ) : $remainder; } /** * Compare * * @param Number $number Number to compare against * @return int Returns 0 if the two numbers are equal, 1 if this number is larger than the $number, -1 otherwise */ public function compare( Number $number ): int { list( $number1, $number2 ) = static::_normaliseTwoNumbers( $this, $number ); if ( $number1->positive and !$number2->positive ) { return 1; } elseif ( !$number1->positive and $number2->positive ) { return -1; } if ( $number1->beforeDecimalPoint === $number2->beforeDecimalPoint ) { if ( $number1->afterDecimalPoint === $number2->afterDecimalPoint ) { return 0; } elseif ( $number1->afterDecimalPoint > $number2->afterDecimalPoint ) { return 1; } else { return -1; } } elseif ( $number1->beforeDecimalPoint > $number2->beforeDecimalPoint ) { return 1; } else { return -1; } } /** * Round to a number of decimal places * * @param int $decimalPlaces The number of decimal places to round to * @param int $round Handles how to round. See ROUND_* constants * @return Number */ public function round( int $decimalPlaces, int $round = Number::ROUND_NORMAL ): Number { /* If it's already precise enough, we don't need to do anything */ if ( $decimalPlaces >= $this->numberOfDecimalPlaces ) { return clone $this; } /* If we don't know if we're going up or down, figure that out */ if ( $round === static::ROUND_NORMAL ) { $numberToExamine = intval( substr( str_pad( $this->afterDecimalPoint, $this->numberOfDecimalPlaces, '0', STR_PAD_LEFT ), $decimalPlaces, 1 ) ); if ( $numberToExamine >= 5 ) { $round = static::ROUND_UP; } else { $round = static::ROUND_TRUNCATE; } } /* Start with a number without the decimal point, truncated where we need it */ $abs = "{$this->beforeDecimalPoint}" . str_pad( substr( str_pad( $this->afterDecimalPoint, $this->numberOfDecimalPlaces, '0', STR_PAD_LEFT ), 0, $decimalPlaces ), $decimalPlaces, '0', STR_PAD_LEFT ); /* If we're going up, add one on */ if ( $round === static::ROUND_UP ) { $abs = intval( $abs ) + 1; } /* Put the decimal back */ if ( $decimalPlaces ) { return new static( ( $this->positive === FALSE ? "-" : '' ) . substr( $abs, 0, -$decimalPlaces ) . '.' . str_pad( substr( $abs, -$decimalPlaces ), $decimalPlaces, '0', STR_PAD_LEFT ) ); } else { return new static( ( $this->positive === FALSE ? "-" : '' ) . "{$abs}" ); } } /** * Calculate percentage * * @param int|Number $percentage The percentage * @return Number */ public function percentage( Number|int $percentage ): Number { if ( !( $percentage instanceof static ) ) { $percentage = new static( "{$percentage}" ); } return $this->multiply( $percentage->divide( new static( '100' ) ) ); } /** * Get absolute value * * @return Number */ public function absolute(): Number // we need the underscore version as long as we have monkey patching { return $this->positive ? $this : $this->multiply( new Number('-1') ); } /* !Array math */ /** * Get sum of numbers * * @param array $numbers array of \IPS\Math\Number objects * @return Number */ public static function sum( array $numbers ): Number { $result = new static('0'); foreach ( $numbers as $number ) { $result = $result->add( $number ); } return $result; } /** * Get the moving average series from a series of numbers * * @param number[] $numbers * @param int $degree How many elements of the source $numbers to average over * @param float $weightIncrement How much to scale the weight of each value in the moving average as it approaches the current date. The weight starts at 1 and will be multiplied by this number * @return (number|null)[] If the input array contains non-numeric values, these will be omitted from the moving average. If there are no numbers in the average period, null is added to the output array instead of a number */ public static function movingAverage( array $numbers, int $degree=5, float $weightIncrement=1 ) : array { if ( count( $numbers ) <= 1 OR $degree <= 1 ) { return [...$numbers]; } $final = []; $values = array_values( $numbers ); $keys = array_keys( $numbers ); for ( $i = 1; $i < count( $numbers ); $i++ ) { $start = max(0, $i - $degree); $slice = array_slice( $values, $start, $i + 1 - $start ); $sum = 0; $count = 0; $weight = 1; foreach ( $slice as $number ) { if ( !is_numeric( $number ) ) { continue; } $number = ( is_int( $number ) or is_float( $number ) ) ? $number : (float) $number; $sum += $number * $weight; $count += $weight; $weight *= $weightIncrement; } if ( $count === 0 ) { $final[$keys[$i]] = null; continue; } $final[$keys[$i]] = $sum / $count; } return $final; } /** * Get product of numbers * * @param array $numbers array of \IPS\Math\Number objects * @return Number */ public static function product( array $numbers ): Number { $result = new static('0'); foreach ( $numbers as $number ) { $result = $result->multiply( $number ); } return $result; } /* !Utility Methods */ /** * Normalise two numbers * Makes the number of decimal places for both numbers equal so that math can be done on them * * @param Number $number1 Number 1 * @param Number $number2 Number 2 * @return array */ protected static function _normaliseTwoNumbers( Number $number1, Number $number2 ): array { $number1 = clone $number1; $number2 = clone $number2; if ( $number1->numberOfDecimalPlaces != $number2->numberOfDecimalPlaces ) { if ( $number1->numberOfDecimalPlaces > $number2->numberOfDecimalPlaces ) { $number2->_normaliseDecimalPlaces( $number1->numberOfDecimalPlaces ); } else { $number1->_normaliseDecimalPlaces( $number2->numberOfDecimalPlaces ); } } return array( $number1, $number2 ); } /** * Makes the number of decimal places a specific number * * @param int $to The number of decimal places * @return void */ protected function _normaliseDecimalPlaces( int $to ) : void { $this->afterDecimalPoint *= ( ( $to - $this->numberOfDecimalPlaces ) * 10 ); $this->numberOfDecimalPlaces = $to; } /** * Simplifies the number of decimal places to the lowest number necessary * * @return void */ protected function _simplifyDecimalPlaces() : void { if ( $this->afterDecimalPoint === 0 ) { $this->numberOfDecimalPlaces = 0; if ( $this->beforeDecimalPoint === 0 ) { $this->positive = TRUE; } return; } while ( ( $this->afterDecimalPoint % 10 ) === 0 ) { $this->numberOfDecimalPlaces--; $this->afterDecimalPoint /= 10; } } /* !Output */ /** * Convert to string * Not locale aware - uses '.' for decimal points and no thousand separator * * @return string */ public function __toString() { return ( $this->positive ? '' : '-' ) . $this->beforeDecimalPoint . ( $this->numberOfDecimalPlaces ? ( '.' . str_pad( $this->afterDecimalPoint, $this->numberOfDecimalPlaces, '0', STR_PAD_LEFT ) ) : '' ); } /** * Value for json_encode * * @return string */ public function jsonSerialize(): string { return (string) $this; } }