..because that is where it is the most annoying/visible. Otherwise it really has no effect on the LMS or anything else.
419 lines
13 KiB
Python
419 lines
13 KiB
Python
"""
|
|
Parser and evaluator for FormulaResponse and NumericalResponse
|
|
|
|
Uses pyparsing to parse. Main function as of now is evaluator().
|
|
"""
|
|
|
|
import math
|
|
import operator
|
|
import numbers
|
|
import numpy
|
|
import scipy.constants
|
|
import calcfunctions
|
|
|
|
from pyparsing import (
|
|
Word, Literal, CaselessLiteral, ZeroOrMore, MatchFirst, Optional, Forward,
|
|
Group, ParseResults, stringEnd, Suppress, Combine, alphas, nums, alphanums
|
|
)
|
|
|
|
DEFAULT_FUNCTIONS = {
|
|
'sin': numpy.sin,
|
|
'cos': numpy.cos,
|
|
'tan': numpy.tan,
|
|
'sec': calcfunctions.sec,
|
|
'csc': calcfunctions.csc,
|
|
'cot': calcfunctions.cot,
|
|
'sqrt': numpy.sqrt,
|
|
'log10': numpy.log10,
|
|
'log2': numpy.log2,
|
|
'ln': numpy.log,
|
|
'exp': numpy.exp,
|
|
'arccos': numpy.arccos,
|
|
'arcsin': numpy.arcsin,
|
|
'arctan': numpy.arctan,
|
|
'arcsec': calcfunctions.arcsec,
|
|
'arccsc': calcfunctions.arccsc,
|
|
'arccot': calcfunctions.arccot,
|
|
'abs': numpy.abs,
|
|
'fact': math.factorial,
|
|
'factorial': math.factorial,
|
|
'sinh': numpy.sinh,
|
|
'cosh': numpy.cosh,
|
|
'tanh': numpy.tanh,
|
|
'sech': calcfunctions.sech,
|
|
'csch': calcfunctions.csch,
|
|
'coth': calcfunctions.coth,
|
|
'arcsinh': numpy.arcsinh,
|
|
'arccosh': numpy.arccosh,
|
|
'arctanh': numpy.arctanh,
|
|
'arcsech': calcfunctions.arcsech,
|
|
'arccsch': calcfunctions.arccsch,
|
|
'arccoth': calcfunctions.arccoth
|
|
}
|
|
DEFAULT_VARIABLES = {
|
|
'i': numpy.complex(0, 1),
|
|
'j': numpy.complex(0, 1),
|
|
'e': numpy.e,
|
|
'pi': numpy.pi,
|
|
'k': scipy.constants.k, # Boltzmann: 1.3806488e-23 (Joules/Kelvin)
|
|
'c': scipy.constants.c, # Light Speed: 2.998e8 (m/s)
|
|
'T': 298.15, # 0 deg C = T Kelvin
|
|
'q': scipy.constants.e # Fund. Charge: 1.602176565e-19 (Coulombs)
|
|
}
|
|
|
|
# We eliminated the following extreme suffixes:
|
|
# P (1e15), E (1e18), Z (1e21), Y (1e24),
|
|
# f (1e-15), a (1e-18), z (1e-21), y (1e-24)
|
|
# since they're rarely used, and potentially confusing.
|
|
# They may also conflict with variables if we ever allow e.g.
|
|
# 5R instead of 5*R
|
|
SUFFIXES = {
|
|
'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12,
|
|
'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12
|
|
}
|
|
|
|
|
|
class UndefinedVariable(Exception):
|
|
"""
|
|
Indicate when a student inputs a variable which was not expected.
|
|
"""
|
|
pass
|
|
|
|
|
|
def lower_dict(input_dict):
|
|
"""
|
|
Convert all keys in a dictionary to lowercase; keep their original values.
|
|
|
|
Keep in mind that it is possible (but not useful?) to define different
|
|
variables that have the same lowercase representation. It would be hard to
|
|
tell which is used in the final dict and which isn't.
|
|
"""
|
|
return {k.lower(): v for k, v in input_dict.iteritems()}
|
|
|
|
|
|
# The following few functions define evaluation actions, which are run on lists
|
|
# of results from each parse component. They convert the strings and (previously
|
|
# calculated) numbers into the number that component represents.
|
|
|
|
def super_float(text):
|
|
"""
|
|
Like float, but with SI extensions. 1k goes to 1000.
|
|
"""
|
|
if text[-1] in SUFFIXES:
|
|
return float(text[:-1]) * SUFFIXES[text[-1]]
|
|
else:
|
|
return float(text)
|
|
|
|
|
|
def eval_number(parse_result):
|
|
"""
|
|
Create a float out of its string parts.
|
|
|
|
e.g. [ '7.13', 'e', '3' ] -> 7130
|
|
Calls super_float above.
|
|
"""
|
|
return super_float("".join(parse_result))
|
|
|
|
|
|
def eval_atom(parse_result):
|
|
"""
|
|
Return the value wrapped by the atom.
|
|
|
|
In the case of parenthesis, ignore them.
|
|
"""
|
|
# Find first number in the list
|
|
result = next(k for k in parse_result if isinstance(k, numbers.Number))
|
|
return result
|
|
|
|
|
|
def eval_power(parse_result):
|
|
"""
|
|
Take a list of numbers and exponentiate them, right to left.
|
|
|
|
e.g. [ 2, 3, 2 ] -> 2^3^2 = 2^(3^2) -> 512
|
|
(not to be interpreted (2^3)^2 = 64)
|
|
"""
|
|
# `reduce` will go from left to right; reverse the list.
|
|
parse_result = reversed(
|
|
[k for k in parse_result
|
|
if isinstance(k, numbers.Number)] # Ignore the '^' marks.
|
|
)
|
|
# Having reversed it, raise `b` to the power of `a`.
|
|
power = reduce(lambda a, b: b ** a, parse_result)
|
|
return power
|
|
|
|
|
|
def eval_parallel(parse_result):
|
|
"""
|
|
Compute numbers according to the parallel resistors operator.
|
|
|
|
BTW it is commutative. Its formula is given by
|
|
out = 1 / (1/in1 + 1/in2 + ...)
|
|
e.g. [ 1, 2 ] -> 2/3
|
|
|
|
Return NaN if there is a zero among the inputs.
|
|
"""
|
|
if len(parse_result) == 1:
|
|
return parse_result[0]
|
|
if 0 in parse_result:
|
|
return float('nan')
|
|
reciprocals = [1. / e for e in parse_result
|
|
if isinstance(e, numbers.Number)]
|
|
return 1. / sum(reciprocals)
|
|
|
|
|
|
def eval_sum(parse_result):
|
|
"""
|
|
Add the inputs, keeping in mind their sign.
|
|
|
|
[ 1, '+', 2, '-', 3 ] -> 0
|
|
|
|
Allow a leading + or -.
|
|
"""
|
|
total = 0.0
|
|
current_op = operator.add
|
|
for token in parse_result:
|
|
if token == '+':
|
|
current_op = operator.add
|
|
elif token == '-':
|
|
current_op = operator.sub
|
|
else:
|
|
total = current_op(total, token)
|
|
return total
|
|
|
|
|
|
def eval_product(parse_result):
|
|
"""
|
|
Multiply the inputs.
|
|
|
|
[ 1, '*', 2, '/', 3 ] -> 0.66
|
|
"""
|
|
prod = 1.0
|
|
current_op = operator.mul
|
|
for token in parse_result:
|
|
if token == '*':
|
|
current_op = operator.mul
|
|
elif token == '/':
|
|
current_op = operator.truediv
|
|
else:
|
|
prod = current_op(prod, token)
|
|
return prod
|
|
|
|
|
|
def add_defaults(variables, functions, case_sensitive):
|
|
"""
|
|
Create dictionaries with both the default and user-defined variables.
|
|
"""
|
|
all_variables = dict(DEFAULT_VARIABLES)
|
|
all_functions = dict(DEFAULT_FUNCTIONS)
|
|
all_variables.update(variables)
|
|
all_functions.update(functions)
|
|
|
|
if not case_sensitive:
|
|
all_variables = lower_dict(all_variables)
|
|
all_functions = lower_dict(all_functions)
|
|
|
|
return (all_variables, all_functions)
|
|
|
|
|
|
def evaluator(variables, functions, math_expr, case_sensitive=False):
|
|
"""
|
|
Evaluate an expression; that is, take a string of math and return a float.
|
|
|
|
-Variables are passed as a dictionary from string to value. They must be
|
|
python numbers.
|
|
-Unary functions are passed as a dictionary from string to function.
|
|
"""
|
|
# No need to go further.
|
|
if math_expr.strip() == "":
|
|
return float('nan')
|
|
|
|
# Parse the tree.
|
|
math_interpreter = ParseAugmenter(math_expr, case_sensitive)
|
|
math_interpreter.parse_algebra()
|
|
|
|
# Get our variables together.
|
|
all_variables, all_functions = add_defaults(variables, functions, case_sensitive)
|
|
|
|
# ...and check them
|
|
math_interpreter.check_variables(all_variables, all_functions)
|
|
|
|
# Create a recursion to evaluate the tree.
|
|
if case_sensitive:
|
|
casify = lambda x: x
|
|
else:
|
|
casify = lambda x: x.lower() # Lowercase for case insens.
|
|
|
|
evaluate_actions = {
|
|
'number': eval_number,
|
|
'variable': lambda x: all_variables[casify(x[0])],
|
|
'function': lambda x: all_functions[casify(x[0])](x[1]),
|
|
'atom': eval_atom,
|
|
'power': eval_power,
|
|
'parallel': eval_parallel,
|
|
'product': eval_product,
|
|
'sum': eval_sum
|
|
}
|
|
|
|
return math_interpreter.reduce_tree(evaluate_actions)
|
|
|
|
|
|
class ParseAugmenter(object):
|
|
"""
|
|
Holds the data for a particular parse.
|
|
|
|
Retains the `math_expr` and `case_sensitive` so they needn't be passed
|
|
around method to method.
|
|
Eventually holds the parse tree and sets of variables as well.
|
|
"""
|
|
def __init__(self, math_expr, case_sensitive=False):
|
|
"""
|
|
Create the ParseAugmenter for a given math expression string.
|
|
|
|
Do the parsing later, when called like `OBJ.parse_algebra()`.
|
|
"""
|
|
self.case_sensitive = case_sensitive
|
|
self.math_expr = math_expr
|
|
self.tree = None
|
|
self.variables_used = set()
|
|
self.functions_used = set()
|
|
|
|
def vpa(tokens):
|
|
"""
|
|
When a variable is recognized, store it in `variables_used`.
|
|
"""
|
|
varname = tokens[0][0]
|
|
self.variables_used.add(varname)
|
|
|
|
def fpa(tokens):
|
|
"""
|
|
When a function is recognized, store it in `functions_used`.
|
|
"""
|
|
varname = tokens[0][0]
|
|
self.functions_used.add(varname)
|
|
|
|
self.variable_parse_action = vpa
|
|
self.function_parse_action = fpa
|
|
|
|
def parse_algebra(self):
|
|
"""
|
|
Parse an algebraic expression into a tree.
|
|
|
|
Store a `pyparsing.ParseResult` in `self.tree` with proper groupings to
|
|
reflect parenthesis and order of operations. Leave all operators in the
|
|
tree and do not parse any strings of numbers into their float versions.
|
|
|
|
Adding the groups and result names makes the `repr()` of the result
|
|
really gross. For debugging, use something like
|
|
print OBJ.tree.asXML()
|
|
"""
|
|
# 0.33 or 7 or .34 or 16.
|
|
number_part = Word(nums)
|
|
inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part)
|
|
# pyparsing allows spaces between tokens--`Combine` prevents that.
|
|
inner_number = Combine(inner_number)
|
|
|
|
# SI suffixes and percent.
|
|
number_suffix = MatchFirst(Literal(k) for k in SUFFIXES.keys())
|
|
|
|
# 0.33k or 17
|
|
plus_minus = Literal('+') | Literal('-')
|
|
number = Group(
|
|
Optional(plus_minus) +
|
|
inner_number +
|
|
Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part) +
|
|
Optional(number_suffix)
|
|
)
|
|
number = number("number")
|
|
|
|
# Predefine recursive variables.
|
|
expr = Forward()
|
|
|
|
# Handle variables passed in. They must start with letters/underscores
|
|
# and may contain numbers afterward.
|
|
inner_varname = Word(alphas + "_", alphanums + "_")
|
|
varname = Group(inner_varname)("variable")
|
|
varname.setParseAction(self.variable_parse_action)
|
|
|
|
# Same thing for functions.
|
|
function = Group(inner_varname + Suppress("(") + expr + Suppress(")"))("function")
|
|
function.setParseAction(self.function_parse_action)
|
|
|
|
atom = number | function | varname | "(" + expr + ")"
|
|
atom = Group(atom)("atom")
|
|
|
|
# Do the following in the correct order to preserve order of operation.
|
|
pow_term = atom + ZeroOrMore("^" + atom)
|
|
pow_term = Group(pow_term)("power")
|
|
|
|
par_term = pow_term + ZeroOrMore('||' + pow_term) # 5k || 4k
|
|
par_term = Group(par_term)("parallel")
|
|
|
|
prod_term = par_term + ZeroOrMore((Literal('*') | Literal('/')) + par_term) # 7 * 5 / 4
|
|
prod_term = Group(prod_term)("product")
|
|
|
|
sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3
|
|
sum_term = Group(sum_term)("sum")
|
|
|
|
# Finish the recursion.
|
|
expr << sum_term # pylint: disable=W0104
|
|
self.tree = (expr + stringEnd).parseString(self.math_expr)[0]
|
|
|
|
def reduce_tree(self, handle_actions, terminal_converter=None):
|
|
"""
|
|
Call `handle_actions` recursively on `self.tree` and return result.
|
|
|
|
`handle_actions` is a dictionary of node names (e.g. 'product', 'sum',
|
|
etc&) to functions. These functions are of the following form:
|
|
-input: a list of processed child nodes. If it includes any terminal
|
|
nodes in the list, they will be given as their processed forms also.
|
|
-output: whatever to be passed to the level higher, and what to
|
|
return for the final node.
|
|
`terminal_converter` is a function that takes in a token and returns a
|
|
processed form. The default of `None` just leaves them as strings.
|
|
"""
|
|
def handle_node(node):
|
|
"""
|
|
Return the result representing the node, using recursion.
|
|
|
|
Call the appropriate `handle_action` for this node. As its inputs,
|
|
feed it the output of `handle_node` for each child node.
|
|
"""
|
|
if not isinstance(node, ParseResults):
|
|
# Then treat it as a terminal node.
|
|
if terminal_converter is None:
|
|
return node
|
|
else:
|
|
return terminal_converter(node)
|
|
|
|
node_name = node.getName()
|
|
if node_name not in handle_actions: # pragma: no cover
|
|
raise Exception(u"Unknown branch name '{}'".format(node_name))
|
|
|
|
action = handle_actions[node_name]
|
|
handled_kids = [handle_node(k) for k in node]
|
|
return action(handled_kids)
|
|
|
|
# Find the value of the entire tree.
|
|
return handle_node(self.tree)
|
|
|
|
def check_variables(self, valid_variables, valid_functions):
|
|
"""
|
|
Confirm that all the variables used in the tree are valid/defined.
|
|
|
|
Otherwise, raise an UndefinedVariable containing all bad variables.
|
|
"""
|
|
if self.case_sensitive:
|
|
casify = lambda x: x
|
|
else:
|
|
casify = lambda x: x.lower() # Lowercase for case insens.
|
|
|
|
# Test if casify(X) is valid, but return the actual bad input (i.e. X)
|
|
bad_vars = set(var for var in self.variables_used
|
|
if casify(var) not in valid_variables)
|
|
bad_vars.update(func for func in self.functions_used
|
|
if casify(func) not in valid_functions)
|
|
|
|
if bad_vars:
|
|
raise UndefinedVariable(' '.join(sorted(bad_vars)))
|