Invision Power Services, Inc. * @copyright (c) 2001 - 2016 Invision Power Services, Inc. * @license http://www.invisionpower.com/legal/standards/ * @package IPS Community Suite * @since 15 Deb 2016 * @version SVN_VERSION_NUMBER */ namespace IPS\Math; /* To prevent PHP errors (extending class does not exist) revealing path */ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Number Class for precise math */ class _Number implements \JsonSerializable { /* !Bootstrap */ /** * @brief Positive */ protected $positive = TRUE; /** * @brief Number before decimal point */ protected $beforeDecimalPoint = 0; /** * @brief Number of decimal places */ protected $numberOfDecimalPlaces = 0; /** * @brief After decimal point */ protected $afterDecimalPoint = 0; /** * Constructor * * @string $number The number, as a string, using "." for decimal points * @return void * @throws \InvalidArgumentException */ public function __construct( $number ) { /* Check it's valid */ if ( !is_string( $number ) ) { throw new \InvalidArgumentException('NOT_A_STRING'); } 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() { return $this->positive; } /** * Number is zero? * * @return bool */ public function isZero() { return ( !$this->beforeDecimalPoint and !$this->afterDecimalPoint ); } /** * Number is greater than zero? * * @return bool */ public function isGreaterThanZero() { return $this->isPositive() and ( $this->beforeDecimalPoint or $this->afterDecimalPoint ); } /* !Basic Math */ /** * Add * * @param \IPS\Math\Number $number Number to add * @return \IPS\Math\Number */ public function add( 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 \IPS\Math\Number $number Number to subtract * @return \IPS\Math\Number */ public function subtract( $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 \IPS\Math\Number $number Number to multiply by * @return \IPS\Math\Number */ public function multiply( $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 \IPS\Math\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 \IPS\Math\Number * @throws \RuntimeException */ public function divide( $number, $precision = 3, $round = \IPS\Math\Number::ROUND_TRUNCATE ) { /* 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++; } /* Work out the remainder from ( $x * ( 10 ^ $precision ) / $number ) */ $result = 0; $this->multiply( new Number( (string) pow( 10, $precision ) ) )->modulus( $number, $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 \IPS\Math\Number $number Number to get modulus of * @param int $precision * @return \IPS\Math\Number */ public function modulus( $number, &$divides = 0 ) { /* 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 \IPS\Math\Number $number Number to divide by * @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 ) { 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 \IPS\Math\Number */ public function round( $decimalPlaces, $round = \IPS\Math\Number::ROUND_NORMAL ) { /* 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->positive === FALSE ? "-" : '' ) . "{$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 ) { if ( $this->positive ) { $abs = intval( $abs ) + 1; } else { $abs = intval( $abs ) - 1; } } /* Put the decimal back */ return new static( \substr( $abs, 0, -$decimalPlaces ) . '.' . str_pad( \substr( $abs, -$decimalPlaces ), $decimalPlaces, '0', STR_PAD_LEFT ) ); } /** * Calculate percentage * * @param int|\IPS\Math\Number $percentage The percentage * @return \IPS\Math\Number */ public function percentage( $percentage ) { if ( !( $percentage instanceof static ) ) { $percentage = new static( "{$percentage}" ); } return $this->multiply( $percentage->divide( new static( '100' ) ) ); } /* !Array math */ /** * Get sum of numbers * * @param array $numbers array of \IPS\Math\Number objects * @return \IPS\Math\Number */ public static function sum( array $numbers ) { $result = new static('0'); foreach ( $numbers as $number ) { $result = $result->add( $number ); } return $result; } /** * Get product of numbers * * @param array $numbers array of \IPS\Math\Number objects * @return \IPS\Math\Number */ public static function product( array $numbers ) { $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 \IPS\Math\Number $number1 Number 1 * @param \IPS\Math\Number $number2 Number 2 * @return array */ protected static function _normaliseTwoNumbers( Number $number1, Number $number2 ) { $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( $to ) { $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() { 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() { return (string) $this; } }