UNPKG

btm-expressions

Version:

BTM (bowtie-math) is a math object model to enable parsing of mathematical expressions into a tree structure that can be manipulated, evaluated, and compared.

918 lines (839 loc) 31.5 kB
/*! * BTM JavaScript Library v@VERSION * https://github.com/dbrianwalton/BTM * * Copyright D. Brian Walton * Released under the MIT license (https://opensource.org/licenses/MIT) * * Date: @DATE */ /* *********************** ** Evaluating expressions occurs in the context of a BTM environment. ************************* */ import { defaultReductions, defaultSumReductions, defaultProductReductions, disableRule, newRule, findMatchRules } from "./reductions.js" import { create_scalar } from "./scalar_expr.js"; import { variable_expr, index_expr } from "./variable_expr.js"; import { unop_expr } from "./unop_expr.js"; import { binop_expr } from "./binop_expr.js"; import { multiop_expr } from "./multiop_expr.js"; import { function_expr } from "./function_expr.js"; import { deriv_expr } from "./deriv_expr.js"; import { RNG } from "./random.js" import { expression, undef_expr } from "./expression.js"; export const opPrec = { disj: 0, conj: 1, equal: 2, addsub: 3, multdiv: 4, power: 5, fcn: 6, fop: 7 }; export const exprType = { undef: -1, number: 0, variable: 1, fcn: 2, unop: 3, binop: 4, multiop: 5, operator: 6, array: 7, matrix: 8 }; export const exprValue = { undef: -1, bool : 0, numeric : 1 }; export function toTeX(expr) { return typeof expr.toTeX === "function" ? expr.toTeX() : expr; } // Class to define parsing and reduction rules. export class MENV { constructor(settings) { if (settings === undefined) { settings = {}; settings.seed = '1234'; } // Each instance of BTM environment needs bindings across all expressions. this.randomBindings = {}; this.bindings = {}; this.functions = {}; this.opPrec = opPrec; this.exprType = exprType; this.exprValue = exprValue; this.options = { negativeNumbers: true, absTol: 1e-8, relTol: 1e-4, useRelErr: true, doFlatten: false }; this.setReductionRules(); this.multiop_expr = multiop_expr; this.binop_expr = binop_expr; // Generate a random generator. We might be passed either a pre-seeded `rand` function or a seed for our own. let rngOptions = {}; if (typeof settings.rand !== 'undefined') { rngOptions.rand = settings.rand; } if (typeof settings.seed !== 'undefined') { rngOptions.seed = settings.seed; } rngOptions.absTol = this.options.absTol; this.rng = new RNG(rngOptions); } // Perform approximate comparison tests using environment settings // a < b: -1 // a ~= b: 0 // a > b: 1 numberCmp(a,b,override) { // Work with actual values. var valA, valB, cmpResult; var useRelErr = this.options.useRelErr, relTol = this.options.relTol, absTol = this.options.absTol; if (typeof a === 'number' || typeof a === 'Number') { valA = a; } else { valA = a.value(); } if (typeof b === 'number' || typeof b === 'Number') { valB = b; } else { valB = b.value(); } // Pull out the options. if (typeof override !== 'undefined') { if (typeof override.useRelErr !== 'undefined') { useRelErr = override.useRelErr; } if (typeof override.relTol !== 'undefined') { relTol = override.relTol; } if (typeof override.absTol !== 'undefined') { absTol = override.absTol; } } if (!useRelErr || Math.abs(valA) < absTol) { if (Math.abs(valB-valA) < absTol) { cmpResult = 0; } else if (valA < valB) { cmpResult = -1; } else { cmpResult = 1; } } else { if (Math.abs(valB-valA)/Math.abs(valA) < relTol) { cmpResult = 0; } else if (valA < valB) { cmpResult = -1; } else { cmpResult = 1; } } return cmpResult; } /* Block of methods to deal with reduction rules in context */ setReductionRules() { this.reduceRules = defaultReductions(this); } addReductionRule(equation, description, useOneWay) { newRule(this, this.reduceRules, equation, description, true, useOneWay); } disableReductionRule(equation) { disableRule(this, this.reduceRules, equation); } addRule(ruleList, equation, description, useOneWay){ newRule(this, ruleList, equation, description, true, useOneWay); } findMatchRules(reductionList, testExpr, doValidate) { return findMatchRules(reductionList, testExpr, doValidate); } generateRandom(distr, options) { var rndVal, rndScalar; var min, max, by, nonzero; switch (distr) { case 'uniform': min=options.min; if (typeof min.value === 'function') { min = min.value(); } if (min == undefined) { min = 0; } max=options.max; if (typeof max.value === 'function') { max = max.value(); } if (max == undefined) { max = 1; } rndVal = this.rng.randUniform(min,max); break; case 'sign': rndVal = this.rng.randSign(); break; case 'integer': min=options.min; if (typeof min.value === 'function') { min = min.value(); } if (min == undefined) { min = 0; } min = Math.floor(min); max=options.max; if (typeof max.value === 'function') { max = max.value(); } if (max == undefined) { max = 1; } max = Math.floor(max); rndVal = this.rng.randInt(min,max); break; case 'discrete': min = options.min; if (typeof min.value === 'function') { min = min.value(); } max = options.max; if (typeof max.value === 'function') { max = max.value(); } by = options.by; if (typeof by.value === 'function') { by = by.value(); } nonzero = options.nonzero ? true : false; rndVal = this.rng.randDiscrete(min,max,by,nonzero); break; } rndScalar = create_scalar(this, rndVal); return rndScalar; } addFunction(name, input, expression) { if (arguments.length < 2) { input = "x"; } // No expression? Make it random. if (arguments.length < 3) { var formula = create_scalar(this, this.rng.randRational([-20,20],[1,15])); var newTerm; for (var i=1; i<=6; i++) { if (Array.isArray(input)) { newTerm = this.parse("sin("+i+"*"+input[0]+")", "formula"); for (var j=1; j<input.length; j++) { newTerm = new binop_expr(this, "*", this.parse("sin("+i+"*"+input[j]+")", "formula"), newTerm ); } } else { newTerm = this.parse("sin("+i+"*"+input+")", "formula"); } newTerm = new binop_expr(this, "*", create_scalar(this, this.rng.randRational([-20,20],[1,10])), newTerm); formula = new binop_expr(this, "+", formula, newTerm); } expression = formula; } var functionEntry = {}; functionEntry["input"] = input; functionEntry["value"] = expression; this.functions[name] = functionEntry; } compareMathObjects(expr1, expr2) { if (typeof expr1 === 'string') { expr1 = this.parse(expr1, "formula") } if (typeof expr2 === 'string') { expr2 = this.parse(expr2, "formula") } return (expr1.compare(expr2)); } getParser(context) { var self=this, parseContext=context; return (function(exprString){ return self.parse(exprString, parseContext); }) } /* **************************************** parse() is the workhorse. Take a string representing a formula, and decompose it into an appropriate tree structure suitable for recursive evaluation of the function. Returns the root element to the tree. ***************************************** */ parse(formulaStr, context, bindings, options) { if (arguments.length < 2) { context = "formula"; } if (arguments.length < 3) { bindings = {}; } if (arguments.length < 4) { options = {}; } const numberMatch = /\d|(\.\d)/; const nameMatch = /[a-zA-Z]/; const unopMatch = /[\+\-/]/; const opMatch = /[\+\-*/^=\$&]/; var charPos = 0, endPos; var parseError = ''; // Strip any extraneous white space and parentheses. var workingStr; workingStr = formulaStr.trim(); // Test if parentheses are all balanced. var hasExtraParens = true; while (hasExtraParens) { hasExtraParens = false; if (workingStr.charAt(0) == '(') { var endExpr = completeParenthesis(workingStr, 0); if (endExpr+1 >= workingStr.length) { hasExtraParens = true; workingStr = workingStr.slice(1,-1); } } } // We build the tree as it is parsed. // Two stacks keep track of operands (expressions) and operators // which we will identify as the string is parsed left to right // At the time an operand is parsed, we don't know to which operator // it ultimately belongs, so we push it onto a stack until we know. var operandStack = new Array(); var operatorStack = new Array(); // When an operator is pushed, we want to compare it to the previous operator // and see if we need to apply the operators to some operands. // This is based on operator precedence (order of operations). // An empty newOp means to finish resolve the rest of the stacks. function resolveOperator(menv, operatorStack, operandStack, newOp) { // Test if the operator has lower precedence. var oldOp = 0; while (operatorStack.length > 0) { oldOp = operatorStack.pop(); if (newOp && (newOp.type==exprType.unop || oldOp.prec < newOp.prec)) { break; } // To get here, the new operator must be *binary* // and the operator to the left has *higher* precedence. // So we need to peel off the operator to the left with its operands // to form an expression as a new compound operand for the new operator. var newExpr; // Unary: Either negative or reciprocal require *one* operand if (oldOp.type == exprType.unop) { if (operandStack.length > 0) { var input = operandStack.pop(); // Deal with negative numbers separately. if (menv.options.negativeNumbers && input.type == exprType.number && oldOp.op == '-') { newExpr = create_scalar(menv, input.number.multiply(-1)); } else { newExpr = new unop_expr(menv, oldOp.op, input); } } else { newExpr = new expression(menv); newExpr.setParsingError("Incomplete formula: missing value for " + oldOp.op); } // Binary: Will be *two* operands. } else { if (operandStack.length > 1) { var inputB = operandStack.pop(); var inputA = operandStack.pop(); newExpr = new binop_expr(menv, oldOp.op, inputA, inputB); } else { newExpr = new expression(menv); newExpr.setParsingError("Incomplete formula: missing value for " + oldOp.op); } } operandStack.push(newExpr); oldOp = 0; } // The new operator is unary or has higher precedence than the previous op. // We need to push the old operator back on the stack to use later. if (oldOp != 0) { operatorStack.push(oldOp); } // A new operation was added to deal with later. if (newOp) { operatorStack.push(newOp); } } // Now we begin to process the string representing the expression. var lastElement = -1, newElement; // 0 for operand, 1 for operator. // Read string left to right. // Identify what type of math object starts at this character. // Find the other end of that object by completion. // Interpret that object, possibly through a recursive parsing. for (charPos = 0; charPos<workingStr.length; charPos++) { // Identify the next element in the string. if (workingStr.charAt(charPos) == ' ') { continue; // It might be a close parentheses that was not matched on the left. } else if (workingStr.charAt(charPos) == ')') { // Treat this like an implicit open parenthesis on the left. resolveOperator(this, operatorStack, operandStack); newElement = 0; lastElement = -1; // It could be an expression surrounded by parentheses -- use recursion } else if (workingStr.charAt(charPos) == '(') { endPos = completeParenthesis(workingStr, charPos); var subExprStr = workingStr.slice(charPos+1,endPos); var subExpr = this.parse(subExprStr, context, bindings); operandStack.push(subExpr); newElement = 0; charPos = endPos; // It could be an absolute value } else if (workingStr.charAt(charPos) == '|') { endPos = completeAbsValue(workingStr, charPos); var subExprStr = workingStr.slice(charPos+1,endPos); var subExpr = this.parse(subExprStr, context, bindings); var newExpr = new function_expr(this, 'abs', subExpr); operandStack.push(newExpr); newElement = 0; charPos = endPos; // It could be a number. Just read it off } else if (workingStr.substr(charPos).search(numberMatch) == 0) { endPos = completeNumber(workingStr, charPos, options); var newExpr = create_scalar(this, new Number(workingStr.slice(charPos, endPos))); if (options && options.noDecimals && workingStr.charAt(charPos) == '.') { newExpr.setParsingError("Whole numbers only. No decimal values are allowed.") } operandStack.push(newExpr); newElement = 0; charPos = endPos-1; // It could be a name, either a function or variable. } else if (workingStr.substr(charPos).search(nameMatch) == 0) { endPos = completeName(workingStr, charPos); var theName = workingStr.slice(charPos,endPos); // If not a known name, break it down using composite if possible. if (bindings[theName]=== undefined) { // Returns the first known name, or theName not composite. var testResults = TestNameIsComposite(theName, bindings); if (testResults.isComposite) { theName = testResults.name; endPos = charPos + theName.length; } } // Test if a function. // Expand this once we allow parsing of user-defined functions. if (workingStr.charAt(endPos) == '(' && (bindings[theName]===undefined)) { var endParen = completeParenthesis(workingStr, endPos); var fcnName = theName; var newExpr; // See if this is a derivative if (fcnName == 'D') { var expr, ivar, ivarValue; var entries = workingStr.slice(endPos+1,endParen).split(","); expr = this.parse(entries[0], context, bindings); if (entries.length == 1) { newExpr = new deriv_expr(this, expr, 'x'); } else { ivar = this.parse(entries[1], context, bindings); // D(f(x),x,c) means f'(c) if (entries.length > 2) { ivarValue = this.parse(entries[2], context, bindings); } newExpr = new deriv_expr(this, expr, ivar, ivarValue); } } else { var subExpr = this.parse(workingStr.slice(endPos+1,endParen), context, bindings); newExpr = new function_expr(this, theName, subExpr); } operandStack.push(newExpr); newElement = 0; charPos = endParen; } // or a variable. else { // Test if needs index if (workingStr.charAt(endPos) == '[') { var endParen, hasError=false; try { endParen = completeBracket(workingStr, endPos, true); } catch (error) { parseError = error; hasError = true; endParen = endPos+1; } var indexExpr = this.parse(workingStr.slice(endPos+1,endParen), context, bindings); var newExpr = new index_expr(this, theName, indexExpr); if (hasError) { newExpr.setParsingError(parseError); parseError = ""; } operandStack.push(newExpr); newElement = 0; charPos = endParen; } else { var newExpr = new variable_expr(this, theName); operandStack.push(newExpr); newElement = 0; charPos = endPos-1; } } // It could be an operator. } else if (workingStr.substr(charPos).search(opMatch) == 0) { newElement = 1; var op = workingStr.charAt(charPos); var newOp = new operator(op); // Consecutive operators? Better be sign change or reciprocal. if (lastElement != 0) { if (op == "-" || op == "/") { newOp.type = exprType.unop; newOp.prec = opPrec.multdiv; } else { // ERROR!!! parseError = "Error: consecutive operators"; } } resolveOperator(this, operatorStack, operandStack, newOp); } // Two consecutive operands must have an implicit multiplication between them if (lastElement == 0 && newElement == 0) { var holdElement = operandStack.pop(); // Push a multiplication var newOp = new operator('*'); resolveOperator(this, operatorStack, operandStack, newOp); // Then restore the operand stack. operandStack.push(holdElement); } lastElement = newElement; } // Now finish up the operator stack: nothing new to include resolveOperator(this, operatorStack, operandStack); var finalExpression; if (operandStack.length > 0) { finalExpression = operandStack.pop(); } else { finalExpression = new undef_expr(this); } if (parseError.length > 0) { finalExpression.setParsingError(parseError); } else { // Substitute any expressions provided finalExpression = finalExpression.compose(bindings); // Test if context is consistent switch (context) { case 'number': if (!finalExpression.isConstant()) { throw new TypeError(`The expression ${formulaStr} is expected to be a constant but depends on variables.`); } finalExpression.simplifyConstants(); break; case 'formula': break; } finalExpression.setContext(context); } if (options.doFlatten) { finalExpression.flatten(); } return finalExpression; } } // Used in parse function operator(opStr) { this.op = opStr; switch(opStr) { case '+': case '-': this.prec = opPrec.addsub; this.type = exprType.binop; this.valueType = exprValue.numeric; break; case '*': case '/': this.prec = opPrec.multdiv; this.type = exprType.binop; this.valueType = exprValue.numeric; break; case '^': this.prec = opPrec.power; this.type = exprType.binop; this.valueType = exprValue.numeric; break; case '&': this.prec = opPrec.conj; this.type = exprType.binop; this.valueType = exprValue.bool; break; case '$': // $=or since |=absolute value bar // this.op = '|' this.prec = opPrec.disj; this.type = exprType.binop; this.valueType = exprValue.bool; break; case '=': this.prec = opPrec.equal; this.type = exprType.binop; this.valueType = exprValue.bool; break; case ',': this.prec = opPrec.fop; this.type = exprType.array; this.valueType = exprValue.vector; break; default: this.prec = opPrec.fcn; this.type = exprType.fcn; break; } } /* An absolute value can be complicated because also a function. May not be clear if nested: |2|x-3|- 5|. Is that 2x-15 or abs(2|x-3|-5)? Resolve by requiring explicit operations: |2*|x-3|-5| or |2|*x-3*|-5| */ function completeAbsValue(formulaStr, startPos) { var pLevel = 1; var charPos = startPos; var wasOp = true; // open absolute value implicitly has previous operation. while (pLevel > 0 && charPos < formulaStr.length) { charPos++; // We encounter another absolute value. if (formulaStr.charAt(charPos) == '|') { if (wasOp) { // Must be opening a new absolute value. pLevel++; // wasOp is still true since can't close immediately } else { // Assume closing absolute value. If not wanted, need operator. pLevel--; // wasOp is still false since just closed a value. } // Keep track of whether just had operator or not. } else if ("+-*/([".search(formulaStr.charAt(charPos)) >= 0) { wasOp = true; } else if (formulaStr.charAt(charPos) != ' ') { wasOp = false; } } return(charPos); } // Find the balancing closing parenthesis. function completeParenthesis(formulaStr, startPos) { var pLevel = 1; var charPos = startPos; while (pLevel > 0 && charPos < formulaStr.length) { charPos++; if (formulaStr.charAt(charPos) == ')') { pLevel--; } else if (formulaStr.charAt(charPos) == '(') { pLevel++; } } return(charPos); } // Brackets are used for sequence indexing, not regular grouping. function completeBracket(formulaStr, startPos, asSubscript) { var pLevel = 1; var charPos = startPos; var fail = false; while (pLevel > 0 && charPos < formulaStr.length) { charPos++; if (formulaStr.charAt(charPos) == ']') { pLevel--; } else if (formulaStr.charAt(charPos) == '[') { if (asSubscript) { fail = true; } pLevel++; } } if (asSubscript && fail) { throw "Nested brackets used for subscripts are not supported."; } return(charPos); } /* Given a string and a starting position of a name, identify the entire name. */ /* Require start with letter, then any sequence of *word* character */ /* Also allow primes for derivatives at the end. */ function completeName(formulaStr, startPos) { var matchRule = /[A-Za-z]\w*'*/; var match = formulaStr.substr(startPos).match(matchRule); return(startPos + match[0].length); } /* Given a string and a starting position of a number, identify the entire number. */ function completeNumber(formulaStr, startPos, options) { var matchRule; if (options && options.noDecimals) { matchRule = /[0-9]*/; } else { matchRule = /[0-9]*(\.[0-9]*)?(e-?[0-9]+)?/; } var match = formulaStr.substr(startPos).match(matchRule); return(startPos + match[0].length); } /* Tests a string to see if it can be constructed as a concatentation of known names. */ /* For example, abc could be a name or could be a*b*c */ /* Pass in the bindings giving the known names and see if we can build this name */ /* Return the *first* name that is part of the whole. */ function TestNameIsComposite(text, bindings) { var retStruct = new Object(); retStruct.isComposite = false; if (bindings !== undefined) { var remain, nextName; if (bindings[text] !== undefined) { retStruct.isComposite = true; retStruct.name = text; } else { // See if the text *starts* with a known name var knownNames = Object.keys(bindings); for (var ikey in knownNames) { nextName = knownNames[ikey]; // If *this* name is the start of the text, see if the rest from known names. if (text.search(nextName)==0) { remain = TestNameIsComposite(text.slice(nextName.length), bindings); if (remain.isComposite) { retStruct.isComposite = true; retStruct.name = nextName; break; } } } } } return retStruct; } export class BTM { constructor(settings) { this.menv = new MENV(settings); // Each instance of BTM environment needs bindings across all expressions. this.data = {}; this.data.allValues = {}; this.data.params = {}; this.data.variables = {}; this.data.expressions = {}; } addMathObject(name, context, newObject) { switch(context) { case 'number': if (newObject.isConstant()) { this.data.params[name] = newObject; this.data.allValues[name] = newObject; } else { throw `Attempt to add math object '${name}' with context '${context}' that does not match.`; } break; case 'formula': this.data.allValues[name] = newObject; break; } return newObject; } generateRandom(distr, options) { return this.menv.generateRandom(distr,options); } addVariable(name, options) { var newVar = new variable_expr(this.menv, name); this.data.variables[name] = newVar; this.data.allValues[name] = newVar; return newVar; } parseExpression(expression, context) { var newExpr; // Not yet parsed if (typeof expression === 'string') { var formula = this.decodeFormula(expression); newExpr = this.menv.parse(formula, context, this.data.allValues) .compose(this.data.allValues); // Already parsed } else if (typeof expression === 'object') { newExpr = expression; } return newExpr; } evaluateExpression(expression, context, bindings) { var theExpr, newExpr, retValue; // Not yet parsed if (typeof expression === 'string') { var formula = this.decodeFormula(expression); theExpr = this.menv.parse(formula, "formula"); // Already parsed } else if (typeof expression === 'object') { theExpr = expression; } retValue = theExpr.evaluate(bindings); newExpr = create_scalar(this.menv, retValue); return newExpr; } composeExpression(expression, substitution) { var myExpr; // Not yet parsed if (typeof expression === 'string') { var formula = this.decodeFormula(expression); myExpr = this.menv.parse(formula, "formula"); // Already parsed } else if (typeof expression === 'object') { myExpr = expression; } var mySubs = Object.entries(substitution); var substVar, substExpr; [substVar, substExpr] = mySubs[0]; if (typeof substExpr == "string") { substExpr = this.menv.parse(substExpr, "formula"); } var binding = {}; binding[substVar] = substExpr; return myExpr.compose(binding); } addExpression(name, expression) { var newExpr = this.parseExpression(expression, "formula"); this.data.expressions[name] = newExpr; this.data.allValues[name] = newExpr; return newExpr; } addFunction(name, input, expression) { this.menv.addFunction(name, input, expression); } // This routine takes the text and looks for strings in mustaches {{name}} // It replaces this element with the corresponding parameter, variable, or expression. // These should have been previously parsed and stored in this.data. decodeFormula(statement, displayMode) { // First find all of the expected substitutions. var substRequestList = {}; var matchRE = /\{\{[A-Za-z]\w*\}\}/g; var substMatches = statement.match(matchRE); if (substMatches != null) { for (var i=0; i<substMatches.length; i++) { var matchName = substMatches[i]; matchName = matchName.substr(2,matchName.length-4); // Now see if the name is in our substitution rules. if (this.data.allValues[matchName] != undefined) { if (displayMode != undefined && displayMode) { substRequestList[matchName] = '{'+this.data.allValues[matchName].toTeX()+'}'; } else { substRequestList[matchName] = '('+this.data.allValues[matchName].toString()+')'; } } } } // We are now ready to make the substitutions. var retString = statement; for (var match in substRequestList) { var re = new RegExp("{{" + match + "}}", "g"); var subst = substRequestList[match]; retString = retString.replace(re, subst); } return retString; } compareExpressions(expr1, expr2) { var myExpr1, myExpr2; // Not yet parsed if (typeof expr1 === 'string') { var formula1 = this.decodeFormula(expr1); myExpr1 = this.menv.parse(formula1, "formula"); // Already parsed } else if (typeof expr1 === 'object') { myExpr1 = expr1; } if (typeof expr2 === 'string') { var formula2 = this.decodeFormula(expr2); myExpr2 = this.menv.parse(formula2, "formula"); // Already parsed } else if (typeof expr2 === 'object') { myExpr2 = expr2; } return this.menv.compareMathObjects(myExpr1,myExpr2); } getParser(context) { return this.menv.getParser(context); } }