closedquestion-8.x-3.x-dev/src/Utility/EvalMath.php
src/Utility/EvalMath.php
<?php
namespace Drupal\closedquestion\Utility;
use Drupal\Core\Messenger\MessengerInterface;
/**
* Class EvalMath.
*
* EvalMath - PHP Class to safely evaluate math expressions.
*
* Copyright (C) 2006-2007 Zack Bloom
* Miles Kaufmann - EvalMath Class Library
* Hylke van der Schaaf - ClosedQeustion/Drupal integration
*
* Modifications for use in ClosedQuestion:
* - Function handling rewritten to allow functions with 0 and 2 or more
* arguments.
* - Removed uses of eval since php supports variable functions.
* - Added lcg_value & random function.
* - Added round and 2-argument number_format (nf) function.
* - Added min, max, ceil, floor.
* - Added setVars method to load variables.
* - Trigger now throws drupal error messages instead of php errors.
* - Wrapped messages in t().
* - Converted constructor to __construct().
* - Added docs to fields and methods.
* - Changed use of var to private, all properties are private now.
* - Added getters and setters for some now-private properties.
* - Public/Private modifiers for all functions.
* - More descriptive variable names.
* - Added stat functions.
* - Allowed for variable number of variables by setting the count to -1
*/
class EvalMath {
/**
* Flag to indicate wether errors should be shown or suppressed.
*
* @var bool
*/
private $suppressErrors = FALSE;
/**
* The last error that was generated.
*
* @var string
*/
private $lastError = NULL;
/**
* The list of currently available variables and constants.
*
* @var array
*/
private $variables = array();
/**
* The list of currently available user-defined functions.
*
* @var array
*/
private $funcUser = array();
/**
* The list of constant names.
*
* @var array
*/
private $constants = array();
/**
* The list of build-in functions.
*
* Each item has the function name as key and the argument count as value.
*
* @var array
*/
private $funcBuildIn = array(
'abs' => 1,
'acos' => 1,
'acosh' => 1,
'asin' => 1,
'asinh' => 1,
'atan' => 1,
'atanh' => 1,
'ceil' => 1,
'cos' => 1,
'cosh' => 1,
'sqrt' => 1,
'floor' => 1,
'formatPlusminusSign' => -1,
'linregFormula' => -1,
'linreg_slope' => -1,
'linregIntercept' => -1,
'lcg_value' => 0,
'log' => -1,
'max' => 2,
'mean' => -1,
'median' => -1,
'min' => 2,
'number_format' => 2,
'round' => 2,
'sin' => 1,
'sinh' => 1,
'stdev' => -1,
'sum' => -1,
'tan' => 1,
'tanh' => 1,
);
/**
* A list of all functions with dynamic amounts of arguments.
*
* @var array
*/
private $dynamicArgFunc = array();
/**
* A list of the number of arguments used for all functions in expression.
*
* @var array
*/
private $usedFuncArgCount = array();
/**
* A list of function names that are aliases of other functions.
*
* Both the function and the alias needs to be in the $fb table for now.
*
* @var array
*/
private $funcAliases = array(
'arcsin' => 'asin',
'arccos' => 'acos',
'arctan' => 'atan',
'arcsinh' => 'asinh',
'arccosh' => 'acosh',
'arctanh' => 'atanh',
'ln' => 'log',
'nf' => 'number_format',
'random' => 'lcg_value',
);
/**
* Messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
private $messenger;
/**
* Constructs a new EvalMath object.
*/
public function __construct(MessengerInterface $messenger) {
$this->messenger = $messenger;
// Keep track of functions with dynamic number of arguments.
foreach ($this->funcBuildIn as $fnn => $argsCnt) {
if ($argsCnt < 0) {
$this->dynamicArgFunc[] = $fnn;
}
}
}
/**
* Evaluate the given expression and return the result.
*
* This is an alias for evaluate().
*
* @param string $expr
* The expression to evaluate.
*
* @return bool
* The result of the evaluation.
*/
public function e($expr) {
return $this->evaluate($expr);
}
/**
* Evaluate the given expression and return the result.
*
* @param string $expr
* The expression to evaluate.
*
* @return bool
* The result of the evaluation.
*/
public function evaluate($expr) {
$this->lastError = NULL;
$expr = mb_strtolower(trim($expr));
if (mb_substr($expr, -1, 1) === ';') {
$expr = mb_substr($expr, 0, -1);
}
if (preg_match('/^\s*([a-zA-Z]\w*)\s*=\s*(.+)$/', $expr, $matches)) {
if (in_array($matches[1], $this->constants)) {
return $this->trigger(t('cannot assign to constant "%m"', array('%m' => $matches[1])));
}
if (($tmp = $this->postfixEvaluate($this->infixToPostfix($matches[2]))) === FALSE) {
return FALSE;
}
$this->variables[$matches[1]] = $tmp;
return $this->variables[$matches[1]];
}
elseif (preg_match('/^\s*([a-zA-Z]\w*)\s*\(\s*([a-zA-Z]\w*(?:\s*,\s*[a-zA-Z]\w*)*)\s*\)\s*=\s*(.+)$/', $expr, $matches)) {
$fnn = $matches[1];
if (array_key_exists($fnn, $this->funcAliases)) {
// Check if the function is an alias.
$fnn = $this->funcAliases[$fnn];
}
if (array_key_exists($fnn, $this->funcBuildIn)) {
return $this->trigger(t('cannot redefine built-in function "%m()"', array('%m' => $matches[1])));
}
$args = explode(",", preg_replace("/\s+/", "", $matches[2]));
$stack = $this->infixToPostfix($matches[3]);
if ($stack === FALSE) {
return FALSE;
}
for ($i = 0; $i < count($stack); $i++) {
$token = $stack[$i];
if (preg_match('/^[a-zA-Z]\w*$/', $token) && !in_array($token, $args)) {
if (array_key_exists($token, $this->variables)) {
$stack[$i] = $this->variables[$token];
}
else {
return $this->trigger(t('undefined variable "%t" in function definition', array('%t' => $token)));
}
}
}
$this->funcUser[$fnn] = array(
'args' => $args,
'func' => $stack,
'def' => $matches[3],
);
return TRUE;
}
else {
return $this->postfixEvaluate($this->infixToPostfix($expr));
}
}
/**
* Returns an array of all user-defined variables, with their values.
*
* @return array
* A list of all user-defined variables as name=>value pairs.
*/
public function getVars() {
$output = $this->variables;
return $output;
}
/**
* Adds the given variables to the interal set of user-defined variables.
*
* @param array $vars
* An array of name/value pairs, as given by getVars()
*/
public function setVars(array $vars) {
$this->variables = array_merge($this->variables, $vars);
}
/**
* Returns an array of all user-defined functions.
*
* @return array
* A list of all user-defined functions.
*/
public function getFuncs() {
$output = array();
foreach ($this->funcUser as $fnn => $dat) {
$output[$fnn . '(' . implode(',', $dat['args']) . ')'] = $dat['def'];
}
return $output;
}
/**
* Convert infix to postfix notation.
*
* @param string $expr
* The expression to convert.
*
* @return array
* An array with the elements of the expression
*/
private function infixToPostfix($expr) {
$this->usedFuncArgCount = array();
$index = 0;
$stack = new EvalMathStack();
$output = array();
$ops = array('+', '-', '*', '/', '^', '_');
$opsRight = array('+' => 0, '-' => 0, '*' => 0, '/' => 0, '^' => 1);
$opsPrecedence = array(
'+' => 0,
'-' => 0,
'*' => 1,
'/' => 1,
'_' => 1,
'^' => 2,
);
$expectingOp = FALSE;
$inFunction = 0;
$funcHasArg = array();
if (preg_match("/[^\w\s+*^\/()\.,-]/", $expr, $matches)) {
return $this->trigger(t('illegal character "%c"', array('%c' => $matches[0])));
}
while (1) {
$op = mb_substr($expr, $index, 1);
$ex = preg_match('/^([a-z]\w*\(?|\d+(?:\.\d*)?(?:[Ee][+-]?\d*)?|\.\d+|\()/', mb_substr($expr, $index), $match);
if ($op == '-' and !$expectingOp) {
$stack->push('_');
$index++;
}
elseif ($op == '_') {
return $this->trigger(t('illegal character "_"'));
}
elseif ((in_array($op, $ops) || $ex) && $expectingOp) {
if ($ex) {
$op = '*';
$index--;
}
while ($stack->getCount() > 0 && ($o2 = $stack->last()) && in_array($o2, $ops) && ($opsRight[$op] ? $opsPrecedence[$op] < $opsPrecedence[$o2] : $opsPrecedence[$op] <= $opsPrecedence[$o2])) {
$output[] = $stack->pop();
}
$stack->push($op);
$index++;
$expectingOp = FALSE;
}
elseif ($op == ')' && ($expectingOp || $inFunction)) {
while (($o2 = $stack->pop()) != '(') {
if (is_null($o2)) {
return $this->trigger(t('unexpected ) found'));
}
else {
$output[] = $o2;
}
}
if (preg_match("/^([a-z]\w*)\($/", $stack->last(2), $matches)) {
$fnn = $matches[1];
$argCount = $stack->pop();
if ($funcHasArg[$inFunction]) {
$argCount++;
}
if (array_search($fnn, $this->dynamicArgFunc) !== FALSE) {
// Dynamic number of arguments: Adjust argument count.
$this->funcBuildIn[$fnn] = $argCount;
}
// Store the argument count for this function.
$this->usedFuncArgCount[] = $argCount;
// Pop the function and push onto the output.
$output[] = $stack->pop();
if (array_key_exists($fnn, $this->funcAliases)) {
// Check if the function is an alias.
$fnn = $this->funcAliases[$fnn];
}
if (array_key_exists($fnn, $this->funcBuildIn)) {
}
elseif (array_key_exists($fnn, $this->funcUser)) {
if ($argCount != count($this->funcUser[$fnn]['args'])) {
return $this->trigger(t('wrong number of arguments (@gc given, @ec expected)', array('@gc' => $argCount, '@ec' => count($this->funcUser[$fnn]['args']))));
}
}
else {
return $this->trigger(t('internal error, not a function'));
}
$inFunction--;
}
$index++;
}
elseif ($op == ',' and $expectingOp) {
while (($o2 = $stack->pop()) != '(') {
if (is_null($o2)) {
return $this->trigger(t('unexpected , found'));
}
else {
$output[] = $o2;
}
}
if (!preg_match("/^([a-z]\w*)\($/", $stack->last(2), $matches)) {
return $this->trigger(t('unexpected , found'));
}
$stack->push($stack->pop() + 1);
$stack->push('(');
$index++;
$expectingOp = FALSE;
}
elseif ($op == '(' and !$expectingOp) {
$stack->push('(');
$index++;
$allow_neg = TRUE;
}
elseif ($ex and !$expectingOp) {
$expectingOp = TRUE;
$val = $match[1];
if (preg_match("/^([a-z]\w*)\($/", $val, $matches)) {
if (array_key_exists($matches[1], $this->funcAliases) ||
array_key_exists($matches[1], $this->funcBuildIn) ||
array_key_exists($matches[1], $this->funcUser)) {
$stack->push($val);
$stack->push(0);
$stack->push('(');
$expectingOp = FALSE;
// If we are in a function, it'll have at least one argument,
// this one.
if ($inFunction) {
$funcHasArg[$inFunction] = TRUE;
}
$inFunction++;
$funcHasArg[$inFunction] = FALSE;
}
else {
if (!array_key_exists($matches[1], $this->variables)) {
return $this->trigger(t(
'unknown variable or function "%f"',
array('%f' => $matches[1])
));
}
$val = $matches[1];
$output[] = $val;
// If we are in a function, it'll have at least one argument,
// this one.
if ($inFunction) {
$funcHasArg[$inFunction] = TRUE;
}
}
}
else {
$output[] = $val;
// If we are in a function, it'll have at least one argument,
// this one.
if ($inFunction) {
$funcHasArg[$inFunction] = TRUE;
}
}
$index += mb_strlen($val);
}
elseif ($op == ')') {
return $this->trigger(t('unexpected ) found'));
}
elseif (in_array($op, $ops) and !$expectingOp) {
return $this->trigger(t('unexpected operator "%op"', array('%op' => $op)));
}
else {
return $this->trigger(t('an unexpected error occured'));
}
if ($index == mb_strlen($expr)) {
if (in_array($op, $ops)) {
return $this->trigger(t('operator "%op" lacks operand', array('%op' => $op)));
}
else {
break;
}
}
while (mb_substr($expr, $index, 1) == ' ') {
$index++;
}
}
while (!is_null($op = $stack->pop())) {
if ($op == '(') {
return $this->trigger(t('expecting ) but none found'));
}
$output[] = $op;
}
return $output;
}
/**
* Evaluate postfix notation.
*
* @param array|bool $tokens
* The list of tokens that make up the expression.
* @param array $vars
* The list of variables set previously.
*
* @return int
* The final result of the evaluation.
*/
private function postfixEvaluate($tokens, array $vars = array()) {
if ($tokens == FALSE) {
return FALSE;
}
$stack = new EvalMathStack();
foreach ($tokens as $token) {
if (in_array($token, array('+', '-', '*', '/', '^'))) {
if (is_null($op2 = $stack->pop())) {
return $this->trigger(t('internal error'));
}
if (is_null($op1 = $stack->pop())) {
return $this->trigger(t('internal error'));
}
switch ($token) {
case '+':
$stack->push($op1 + $op2);
break;
case '-':
$stack->push($op1 - $op2);
break;
case '*':
$stack->push($op1 * $op2);
break;
case '/':
if ($op2 == 0) {
return $this->trigger(t('division by zero'));
}
$stack->push($op1 / $op2);
break;
case '^':
$stack->push(pow($op1, $op2));
break;
}
}
elseif ($token == "_") {
$stack->push(-1 * $stack->pop());
}
elseif (preg_match("/^([a-z]\w*)\($/", $token, $matches)) {
$fnn = $matches[1];
if (array_key_exists($fnn, $this->funcAliases)) {
$fnn = $this->funcAliases[$fnn];
}
if (array_key_exists($fnn, $this->funcBuildIn)) {
// Get the stored argument count.
$argCount = array_shift($this->usedFuncArgCount);
$args = array();
for ($i = $argCount; $i > 0; $i--) {
$arg = $stack->pop();
if (is_null($arg)) {
return $this->trigger(t('internal error: argument is null'));
}
$args[] = $arg;
}
$args = array_reverse($args);
$stack->push(call_user_func_array($fnn, $args));
}
elseif (array_key_exists($fnn, $this->funcUser)) {
$args = array();
for ($i = count($this->funcUser[$fnn]['args']) - 1; $i >= 0; $i--) {
if (is_null($args[$this->funcUser[$fnn]['args'][$i]] = $stack->pop())) {
return $this->trigger(t('internal error: argument is null'));
}
}
$stack->push($this->postfixEvaluate($this->funcUser[$fnn]['func'], $args));
}
}
else {
if (is_numeric($token)) {
$stack->push($token);
}
elseif (array_key_exists($token, $this->variables)) {
$stack->push($this->variables[$token]);
}
elseif (array_key_exists($token, $vars)) {
$stack->push($vars[$token]);
}
else {
return $this->trigger(t('undefined variable "%token"', array('%token' => $token)));
}
}
}
if ($stack->getCount() != 1) {
return $this->trigger(t('internal error, stack not empty'));
}
return $stack->pop();
}
/**
* Trigger an error, but nicely, if need be.
*
* @param string $msg
* The message that describes the error.
*
* @return bool
* Always returns FALSE
*/
private function trigger($msg) {
$this->lastError = $msg;
if (!$this->suppressErrors) {
$this->messenger->addMessage(t('Math evaluation error: %msg', array('%msg' => $msg)), 'warning');
}
return FALSE;
}
/**
* Getter for the suppressErrors property.
*
* Indicates whether errors are suppressed or returned in Drupal messages.
*
* @return bool
* The value of suppressErrors
*/
public function getSuppressErrors() {
return $this->suppressErrors;
}
/**
* Setter for the suppressErrors property.
*
* Indicates whether errors are suppressed or returned in Drupal messages.
*
* @param bool $suppressErrors
* The new value for suppressErrors.
*/
public function setSuppressErrors($suppressErrors) {
$this->suppressErrors = $suppressErrors;
}
/**
* Returns the last error message that was generated.
*
* @return string
* The last error message
*/
public function getLastError() {
return $this->lastError;
}
/**
* Function to calculate the standard deviation.
*
* @param int
* This function accepts a variable number of numbers.
*
* @return float
* The result.
*/
public function stdev() {
$array = func_get_args();
// Square root of sum of squares devided by N-1.
return sqrt(
array_sum(
array_map(
'stdev_square',
$array,
array_fill(0, count($array), (array_sum($array) / count($array)))
)
) / (count($array) - 1)
);
}
/**
* Function to calculate the mean.
*
* @return float
* The result.
*/
public function mean() {
$array = func_get_args();
$numberOfArgs = count($array);
return array_sum($array) / $numberOfArgs;
}
/**
* Function to calculate the median.
*
* @return float
* The result.
*/
public function median() {
$arr = func_get_args();
if (0 === count($arr)) {
return NULL;
}
// Sort the data.
$count = count($arr);
asort($arr);
// Get the mid-point keys (1 or 2 of them).
$mid = floor(($count - 1) / 2);
$keys = array_slice(array_keys($arr), $mid, (1 === $count % 2 ? 1 : 2));
$sum = 0;
foreach ($keys as $key) {
$sum += $arr[$key];
}
return $sum / count($keys);
}
/**
* Helper function returning number with flat distribution from 0 to 1.
*
* @return float
* Random number.
*/
protected function normalRandom_rnd01() {
return (float) rand() / (float) getrandmax();
}
/**
* Helper function returning number between 0 and 1 with normal distribution.
*
* @return float
* Random number.
*/
protected function normalRandom_gauss() {
$x = $this->normalRandom_rnd01();
$y = $this->normalRandom_rnd01();
return sqrt(-2 * log($x)) * cos(2 * pi() * $y);
}
/**
* Returns a random number with a Normal distribution.
* @param float $m
* The mean.
* @param float $s
* The standard deviation.
*
* @return float
* Random number.
*/
public function normalRandom($m = 0.0, $s = 1.0) {
$returnVal = $this->normalRandom_gauss() * $s + $m;
return $returnVal;
}
/**
* Sum function.
*
* @return int
* Sum value.
*/
public function sum() {
return array_sum(func_get_args());
}
/**
* Linear regression function.
*
* @param array $args
* The x-coords and y-coords.
*
* @return array
* The a and b value of y=ax+b.
*/
public function linreg(array $args) {
$x = $args['x'];
$y = $args['y'];
// Calculate number points.
$n = count($x);
// Calculate sums.
$x_sum = array_sum($x);
$y_sum = array_sum($y);
$xx_sum = 0;
$xy_sum = 0;
for ($i = 0; $i < $n; $i++) {
$xy_sum += ($x[$i] * $y[$i]);
$xx_sum += ($x[$i] * $x[$i]);
}
// Calculate slope.
$m = (($n * $xy_sum) - ($x_sum * $y_sum)) / (($n * $xx_sum) - ($x_sum * $x_sum));
// Calculate intercept.
$b = ($y_sum - ($m * $x_sum)) / $n;
// Return result.
return array("a" => $m, "b" => $b);
}
/**
* Helper function: prepares arrays for linear regression function.
*
* @param array $args
* Array to prepare.
*
* @return array
* Prepared array.
*/
protected function linregPrepareArrays(array $args) {
if (count($args) % 2 !== 0) {
$this->messenger->addMessage('ClosedQuestion: odd number of arguments provided for linear regression. Correct syntax: linreg_a(x1,y1,x2,y2,...)', 'error');
}
// Get two arrays for x and y.
$xVars = array();
$yVars = array();
$numberOfArgs = count($args);
for ($i = 0; $i < $numberOfArgs; $i = $i + 2) {
$xVars[] = $args[$i];
$yVars[] = $args[$i + 1];
}
return array('x' => $xVars, 'y' => $yVars);
}
/**
* Returns the linear regression slope.
*
* @return float
* The slope.
*/
public function linregSlope() {
$regVars = $this->linreg($this->linregPrepareArrays(func_get_args()));
return $regVars['a'];
}
/**
* Returns the linear regression intercept.
*
* @return float
* The slope.
*/
public function linregIntercept() {
$regVars = $this->linreg($this->linregPrepareArrays(func_get_args()));
return $regVars['b'];
}
/**
* Returns a formula for the a number of data points.
*
* @return string
* A formula in the format y = ax + b.
*/
public function linregFormula() {
$template = "y = axb";
$points = func_get_args();
$numberOfPoints = count($points);
$sigFig = 999999999999;
for ($i = 0; $i < $numberOfPoints; $i++) {
$newSigFig = $this->linregSignificantFigures($points[$i]);
$sigFig = $newSigFig < $sigFig ? $newSigFig : $sigFig;
}
$linRegsArrays = $this->linregPrepareArrays($points);
$linRegVars = $this->linreg($linRegsArrays);
$a = $linRegVars['a'];
if ($this->linregSignificantFigures($a) > $sigFig) {
$a = sprintf('%01.' . ($sigFig - 1) . 'f', $a);
}
$b = $linRegVars['b'];
if ($this->linregSignificantFigures($b) > $sigFig) {
$b = $this->formatScientific($b, $sigFig - 1);
}
return str_replace(array('a', 'b'), array($a, $b), $template);
}
/**
* Formats value in scientific format.
*
* @param float $val
* Value.
* @param int $precision
* Precision.
*
* @return string
* Formatted value.
*/
protected function formatScientific($val, $precision = 2) {
$isPositive = $val >= 0 ? TRUE : FALSE;
$absVal = abs($val);
$exp = floor(log($absVal, 10));
$returnValue = sprintf('%.' . $precision . 'fE%+03d', ($isPositive ? $absVal : -$absVal) / pow(10, $exp), $exp);
return $returnValue;
}
/**
* Returns whether a number has a decimal point.
*
* @param string $number
* A number to check.
*
* @return bool
* TRUE if number has a decimal point.
*/
protected function linregHasDecimalpoint($number) {
if (strpos($number, '.') === FALSE) {
return FALSE;
}
return TRUE;
}
/**
* Returns position of 'e' or FALSE.
*
* @param string $number
* A number.
*
* @return int|bool
* The position of 'e' or FALSE.
*/
protected function linregGetExponentPos($number) {
return (strpos($number, "e") !== FALSE ? strpos($number, "e") : (strpos($number, "E") !== FALSE ? strpos($number, "E") : FALSE));
}
/**
* Returns the number of significant figures.
*
* @param string $number
* A number to parse.
*
* @return int
* The number of significant figures.
*/
public function linregSignificantFigures($number) {
$number = (string) $number;
$expPos = $this->linregGetExponentPos($number);
$numberLength = strlen($number);
// Do not include the exponent.
$figures = $expPos !== FALSE ? $expPos : $numberLength;
for ($i = 0; $i < $numberLength; $i++) {
if ($number[$i] == "0" || $number[$i] == "+" || $number[$i] == "-" || $number[$i] == ".") {
if ($number[$i] != ".") {
$figures--;
}
continue;
}
break;
}
if ($this->linregHasDecimalpoint($number)) {
$figures--;
}
return $figures;
}
/**
* Adds a + or a - to the text if it holds a positive or negative number.
*
* @param string $text
* A text to check.
* @param int $precision
* A precision required.
*
* @return string
* A text with + or - sign.
*/
public function formatPlusminusSign($text, $precision = 3) {
return sprintf('%+01.' . ((int) $precision) . 'f', $text);
}
}
