Commit a1162cbb by Peter Baratta

Change calc module

- Create a method called `parse_algebra`. It takes a string of math and returns with a `pyparsing.ParseResults` object representing it.
- `evaluator` takes this tree and applies the old "parse actions" to it to get the same number as it used to.
- Change calc's API: `evaluator` to use `case_sensitive` rather than `cs`
- Add most of the capability for latex rendering
parent 0f9d7230
...@@ -4,129 +4,105 @@ Parser and evaluator for FormulaResponse and NumericalResponse ...@@ -4,129 +4,105 @@ Parser and evaluator for FormulaResponse and NumericalResponse
Uses pyparsing to parse. Main function as of now is evaluator(). Uses pyparsing to parse. Main function as of now is evaluator().
""" """
import copy
import math import math
import operator import operator
import re import numbers
import numpy import numpy
import scipy.constants import scipy.constants
import calcfunctions import calcfunctions
# have numpy raise errors on functions outside its domain # Have numpy ignore errors on functions outside its domain.
# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html # See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html
# TODO worry about thread safety/changing a global setting
numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise' numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise'
from pyparsing import (Word, nums, Literal, from pyparsing import (
ZeroOrMore, MatchFirst, Word, Literal, CaselessLiteral, ZeroOrMore, MatchFirst, Optional, Forward,
Optional, Forward, Group, ParseResults, stringEnd, Suppress, Combine, alphas, nums, alphanums
CaselessLiteral, )
stringEnd, Suppress, Combine)
DEFAULT_FUNCTIONS = {
DEFAULT_FUNCTIONS = {'sin': numpy.sin, 'sin': numpy.sin,
'cos': numpy.cos, 'cos': numpy.cos,
'tan': numpy.tan, 'tan': numpy.tan,
'sec': calcfunctions.sec, 'sec': calcfunctions.sec,
'csc': calcfunctions.csc, 'csc': calcfunctions.csc,
'cot': calcfunctions.cot, 'cot': calcfunctions.cot,
'sqrt': numpy.sqrt, 'sqrt': numpy.sqrt,
'log10': numpy.log10, 'log10': numpy.log10,
'log2': numpy.log2, 'log2': numpy.log2,
'ln': numpy.log, 'ln': numpy.log,
'exp': numpy.exp, 'exp': numpy.exp,
'arccos': numpy.arccos, 'arccos': numpy.arccos,
'arcsin': numpy.arcsin, 'arcsin': numpy.arcsin,
'arctan': numpy.arctan, 'arctan': numpy.arctan,
'arcsec': calcfunctions.arcsec, 'arcsec': calcfunctions.arcsec,
'arccsc': calcfunctions.arccsc, 'arccsc': calcfunctions.arccsc,
'arccot': calcfunctions.arccot, 'arccot': calcfunctions.arccot,
'abs': numpy.abs, 'abs': numpy.abs,
'fact': math.factorial, 'fact': math.factorial,
'factorial': math.factorial, 'factorial': math.factorial,
'sinh': numpy.sinh, 'sinh': numpy.sinh,
'cosh': numpy.cosh, 'cosh': numpy.cosh,
'tanh': numpy.tanh, 'tanh': numpy.tanh,
'sech': calcfunctions.sech, 'sech': calcfunctions.sech,
'csch': calcfunctions.csch, 'csch': calcfunctions.csch,
'coth': calcfunctions.coth, 'coth': calcfunctions.coth,
'arcsinh': numpy.arcsinh, 'arcsinh': numpy.arcsinh,
'arccosh': numpy.arccosh, 'arccosh': numpy.arccosh,
'arctanh': numpy.arctanh, 'arctanh': numpy.arctanh,
'arcsech': calcfunctions.arcsech, 'arcsech': calcfunctions.arcsech,
'arccsch': calcfunctions.arccsch, 'arccsch': calcfunctions.arccsch,
'arccoth': calcfunctions.arccoth 'arccoth': calcfunctions.arccoth
} }
DEFAULT_VARIABLES = {'i': numpy.complex(0, 1), DEFAULT_VARIABLES = {
'j': numpy.complex(0, 1), 'i': numpy.complex(0, 1),
'e': numpy.e, 'j': numpy.complex(0, 1),
'pi': numpy.pi, 'e': numpy.e,
'k': scipy.constants.k, 'pi': numpy.pi,
'c': scipy.constants.c, 'k': scipy.constants.k, # Boltzmann: 1.3806488e-23 (Joules/Kelvin)
'T': 298.15, 'c': scipy.constants.c, # Light Speed: 2.998e8 (m/s)
'q': scipy.constants.e 'T': 298.15, # 0 deg C = T Kelvin
} 'q': scipy.constants.e # Fund. Charge: 1.602176565e-19 (Coulombs)
}
# We eliminated the following extreme suffixes: # We eliminated the following extreme suffixes:
# P (1e15), E (1e18), Z (1e21), Y (1e24), # P (1e15), E (1e18), Z (1e21), Y (1e24),
# f (1e-15), a (1e-18), z (1e-21), y (1e-24) # f (1e-15), a (1e-18), z (1e-21), y (1e-24)
# since they're rarely used, and potentially # since they're rarely used, and potentially confusing.
# confusing. They may also conflict with variables if we ever allow e.g. # They may also conflict with variables if we ever allow e.g.
# 5R instead of 5*R # 5R instead of 5*R
SUFFIXES = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12, SUFFIXES = {
'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12} '%': 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): class UndefinedVariable(Exception):
""" """
Used to indicate the student input of a variable, which was unused by the Indicate when a student inputs a variable which was not expected.
instructor.
""" """
pass pass
def check_variables(string, variables):
"""
Confirm the only variables in string are defined.
Otherwise, raise an UndefinedVariable containing all bad variables.
Pyparsing uses a left-to-right parser, which makes a more
elegant approach pretty hopeless.
"""
general_whitespace = re.compile('[^\\w]+') # TODO consider non-ascii
# List of all alnums in string
possible_variables = re.split(general_whitespace, string)
bad_variables = []
for var in possible_variables:
if len(var) == 0:
continue
if var[0].isdigit(): # Skip things that begin with numbers
continue
if var not in variables:
bad_variables.append(var)
if len(bad_variables) > 0:
raise UndefinedVariable(' '.join(bad_variables))
def lower_dict(input_dict): def lower_dict(input_dict):
""" """
takes each key in the dict and makes it lowercase, still mapping to the Convert all keys in a dictionary to lowercase; keep their original values.
same value.
keep in mind that it is possible (but not useful?) to define different 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 variables that have the same lowercase representation. It would be hard to
tell which is used in the final dict and which isn't. tell which is used in the final dict and which isn't.
""" """
return {k.lower(): v for k, v in input_dict.iteritems()} return {k.lower(): v for k, v in input_dict.iteritems()}
# The following few functions define parse actions, which are run on lists of # The following few functions define evaluation actions, which are run on lists
# results from each parse component. They convert the strings and (previously # of results from each parse component. They convert the strings and (previously
# calculated) numbers into the number that component represents. # calculated) numbers into the number that component represents.
def super_float(text): def super_float(text):
""" """
Like float, but with si extensions. 1k goes to 1000 Like float, but with SI extensions. 1k goes to 1000.
""" """
if text[-1] in SUFFIXES: if text[-1] in SUFFIXES:
return float(text[:-1]) * SUFFIXES[text[-1]] return float(text[:-1]) * SUFFIXES[text[-1]]
...@@ -134,168 +110,314 @@ def super_float(text): ...@@ -134,168 +110,314 @@ def super_float(text):
return float(text) return float(text)
def number_parse_action(parse_result): def eval_number(parse_result):
""" """
Create a float out of its string parts Create a float out of its string parts.
e.g. [ '7', '.', '13' ] -> [ 7.13 ] e.g. [ '7.13', 'e', '3' ] -> 7130
Calls super_float above Calls super_float above.
""" """
return super_float("".join(parse_result)) return super_float("".join(parse_result))
def exp_parse_action(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 Take a list of numbers and exponentiate them, right to left.
e.g. [ 3, 2, 3 ] (which is 3^2^3 = 3^(2^3)) -> 6561 e.g. [ 2, 3, 2 ] -> 2^3^2 = 2^(3^2) -> 512
(not to be interpreted (2^3)^2 = 64)
""" """
# pyparsing.ParseResults doesn't play well with reverse() # `reduce` will go from left to right; reverse the list.
parse_result = reversed(parse_result) parse_result = reversed(
# the result of an exponentiation is called a power [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) power = reduce(lambda a, b: b ** a, parse_result)
return power return power
def parallel(parse_result): def eval_parallel(parse_result):
""" """
Compute numbers according to the parallel resistors operator Compute numbers according to the parallel resistors operator.
BTW it is commutative. Its formula is given by BTW it is commutative. Its formula is given by
out = 1 / (1/in1 + 1/in2 + ...) out = 1 / (1/in1 + 1/in2 + ...)
e.g. [ 1, 2 ] => 2/3 e.g. [ 1, 2 ] -> 2/3
Return NaN if there is a zero among the inputs Return NaN if there is a zero among the inputs.
""" """
# convert from pyparsing.ParseResults, which doesn't support '0 in parse_result'
parse_result = parse_result.asList()
if len(parse_result) == 1: if len(parse_result) == 1:
return parse_result[0] return parse_result[0]
if 0 in parse_result: if 0 in parse_result:
return float('nan') return float('nan')
reciprocals = [1. / e for e in parse_result] reciprocals = [1. / e for e in parse_result
if isinstance(e, numbers.Number)]
return 1. / sum(reciprocals) return 1. / sum(reciprocals)
def sum_parse_action(parse_result): def eval_sum(parse_result):
""" """
Add the inputs Add the inputs, keeping in mind their sign.
[ 1, '+', 2, '-', 3 ] -> 0 [ 1, '+', 2, '-', 3 ] -> 0
Allow a leading + or - Allow a leading + or -.
""" """
total = 0.0 total = 0.0
current_op = operator.add current_op = operator.add
for token in parse_result: for token in parse_result:
if token is '+': if token == '+':
current_op = operator.add current_op = operator.add
elif token is '-': elif token == '-':
current_op = operator.sub current_op = operator.sub
else: else:
total = current_op(total, token) total = current_op(total, token)
return total return total
def prod_parse_action(parse_result): def eval_product(parse_result):
""" """
Multiply the inputs Multiply the inputs.
[ 1, '*', 2, '/', 3 ] => 0.66 [ 1, '*', 2, '/', 3 ] -> 0.66
""" """
prod = 1.0 prod = 1.0
current_op = operator.mul current_op = operator.mul
for token in parse_result: for token in parse_result:
if token is '*': if token == '*':
current_op = operator.mul current_op = operator.mul
elif token is '/': elif token == '/':
current_op = operator.truediv current_op = operator.truediv
else: else:
prod = current_op(prod, token) prod = current_op(prod, token)
return prod return prod
def evaluator(variables, functions, string, cs=False): def add_defaults(variables, functions, case_sensitive):
""" """
Evaluate an expression. Variables are passed as a dictionary Create dictionaries with both the default and user-defined variables.
from string to value. Unary functions are passed as a dictionary
from string to function. Variables must be floats.
cs: Case sensitive
""" """
all_variables = dict(DEFAULT_VARIABLES)
all_variables = copy.copy(DEFAULT_VARIABLES) all_functions = dict(DEFAULT_FUNCTIONS)
all_functions = copy.copy(DEFAULT_FUNCTIONS)
all_variables.update(variables) all_variables.update(variables)
all_functions.update(functions) all_functions.update(functions)
if not cs: if not case_sensitive:
string_cs = string.lower()
all_functions = lower_dict(all_functions)
all_variables = lower_dict(all_variables) all_variables = lower_dict(all_variables)
CasedLiteral = CaselessLiteral all_functions = lower_dict(all_functions)
else:
string_cs = string
CasedLiteral = Literal
check_variables(string_cs, set(all_variables.keys() + all_functions.keys())) return (all_variables, all_functions)
if string.strip() == "":
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') return float('nan')
# SI suffixes and percent # Parse the tree.
number_suffix = MatchFirst([Literal(k) for k in SUFFIXES.keys()]) math_interpreter = ParseAugmenter(math_expr, case_sensitive)
plus_minus = Literal('+') | Literal('-') math_interpreter.parse_algebra()
times_div = Literal('*') | Literal('/')
number_part = Word(nums)
# 0.33 or 7 or .34 or 16.
inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part)
# by default pyparsing allows spaces between tokens--Combine prevents that
inner_number = Combine(inner_number)
# 0.33k or -17
number = (inner_number
+ Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part)
+ Optional(number_suffix))
number.setParseAction(number_parse_action) # Convert to number
# Predefine recursive variables
expr = Forward()
# Handle variables passed in.
# E.g. if we have {'R':0.5}, we make the substitution.
# We sort the list so that var names (like "e2") match before
# mathematical constants (like "e"). This is kind of a hack.
all_variables_keys = sorted(all_variables.keys(), key=len, reverse=True)
varnames = MatchFirst([CasedLiteral(k) for k in all_variables_keys])
varnames.setParseAction(
lambda x: [all_variables[k] for k in x]
)
# if all_variables were empty, then pyparsing wants # Get our variables together.
# varnames = NoMatch() all_variables, all_functions = add_defaults(variables, functions, case_sensitive)
# this is not the case, as all_variables contains the defaults
# Same thing for functions. # ...and check them
all_functions_keys = sorted(all_functions.keys(), key=len, reverse=True) math_interpreter.check_variables(all_variables, all_functions)
funcnames = MatchFirst([CasedLiteral(k) for k in all_functions_keys])
function = funcnames + Suppress("(") + expr + Suppress(")") # Create a recursion to evaluate the tree.
function.setParseAction( if case_sensitive:
lambda x: [all_functions[x[0]](x[1])] 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)
atom = number | function | varnames | Suppress("(") + expr + Suppress(")") if bad_vars:
raise UndefinedVariable(' '.join(sorted(bad_vars)))
# Do the following in the correct order to preserve order of operation
pow_term = atom + ZeroOrMore(Suppress("^") + atom)
pow_term.setParseAction(exp_parse_action) # 7^6
par_term = pow_term + ZeroOrMore(Suppress('||') + pow_term) # 5k || 4k
par_term.setParseAction(parallel)
prod_term = par_term + ZeroOrMore(times_div + par_term) # 7 * 5 / 4 - 3
prod_term.setParseAction(prod_parse_action)
sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3
sum_term.setParseAction(sum_parse_action)
expr << sum_term # finish the recursion
return (expr + stringEnd).parseString(string)[0]
"""
Provide a `latex_preview` method similar in syntax to `evaluator`.
That is, given a math string, parse it and render each branch of the result,
always returning valid latex.
Because intermediate values of the render contain more data than simply the
string of latex, store it in a custom class `LatexRendered`.
"""
from calc import ParseAugmenter, DEFAULT_VARIABLES, DEFAULT_FUNCTIONS, SUFFIXES
class LatexRendered(object):
"""
Data structure to hold a typeset representation of some math.
Fields:
-`latex` is a generated, valid latex string (as if it were standalone).
-`sans_parens` is usually the same as `latex` except without the outermost
parens (if applicable).
-`tall` is a boolean representing if the latex has any elements extending
above or below a normal height, specifically things of the form 'a^b' and
'\frac{a}{b}'. This affects the height of wrapping parenthesis.
"""
def __init__(self, latex, parens=None, tall=False):
"""
Instantiate with the latex representing the math.
Optionally include parenthesis to wrap around it and the height.
`parens` must be one of '(', '[' or '{'.
`tall` is a boolean (see note above).
"""
self.latex = latex
self.sans_parens = latex
self.tall = tall
# Generate parens and overwrite `self.latex`.
if parens is not None:
left_parens = parens
if left_parens == '{':
left_parens = r'\{'
pairs = {'(': ')',
'[': ']',
r'\{': r'\}'}
if left_parens not in pairs:
raise Exception(
u"Unknown parenthesis '{}': coder error".format(left_parens)
)
right_parens = pairs[left_parens]
if self.tall:
left_parens = r"\left" + left_parens
right_parens = r"\right" + right_parens
self.latex = u"{left}{expr}{right}".format(
left=left_parens,
expr=latex,
right=right_parens
)
def __repr__(self): # pragma: no cover
"""
Give a sensible representation of the object.
If `sans_parens` is different, include both.
If `tall` then have '<[]>' around the code, otherwise '<>'.
"""
if self.latex == self.sans_parens:
latex_repr = u'"{}"'.format(self.latex)
else:
latex_repr = u'"{}" or "{}"'.format(self.latex, self.sans_parens)
if self.tall:
wrap = u'<[{}]>'
else:
wrap = u'<{}>'
return wrap.format(latex_repr)
def render_number(children):
"""
Combine the elements forming the number, escaping the suffix if needed.
"""
children_latex = [k.latex for k in children]
suffix = ""
if children_latex[-1] in SUFFIXES:
suffix = children_latex.pop()
suffix = ur"\text{{{s}}}".format(s=suffix)
# Exponential notation-- the "E" splits the mantissa and exponent
if "E" in children_latex:
pos = children_latex.index("E")
mantissa = "".join(children_latex[:pos])
exponent = "".join(children_latex[pos + 1:])
latex = ur"{m}\!\times\!10^{{{e}}}{s}".format(
m=mantissa, e=exponent, s=suffix
)
return LatexRendered(latex, tall=True)
else:
easy_number = "".join(children_latex)
return LatexRendered(easy_number + suffix)
def enrich_varname(varname):
"""
Prepend a backslash if we're given a greek character.
"""
greek = ("alpha beta gamma delta epsilon varepsilon zeta eta theta "
"vartheta iota kappa lambda mu nu xi pi rho sigma tau upsilon "
"phi varphi chi psi omega").split()
if varname in greek:
return ur"\{letter}".format(letter=varname)
else:
return varname.replace("_", r"\_")
def variable_closure(variables, casify):
"""
Wrap `render_variable` so it knows the variables allowed.
"""
def render_variable(children):
"""
Replace greek letters, otherwise escape the variable names.
"""
varname = children[0].latex
if casify(varname) not in variables:
pass # TODO turn unknown variable red or give some kind of error
first, _, second = varname.partition("_")
if second:
# Then 'a_b' must become 'a_{b}'
varname = ur"{a}_{{{b}}}".format(
a=enrich_varname(first),
b=enrich_varname(second)
)
else:
varname = enrich_varname(varname)
return LatexRendered(varname) # .replace("_", r"\_"))
return render_variable
def function_closure(functions, casify):
"""
Wrap `render_function` so it knows the functions allowed.
"""
def render_function(children):
"""
Escape function names and give proper formatting to exceptions.
The exceptions being 'sqrt', 'log2', and 'log10' as of now.
"""
fname = children[0].latex
if casify(fname) not in functions:
pass # TODO turn unknown function red or give some kind of error
# Wrap the input of the function with parens or braces.
inner = children[1].latex
if fname == "sqrt":
inner = u"{{{expr}}}".format(expr=inner)
else:
if children[1].tall:
inner = ur"\left({expr}\right)".format(expr=inner)
else:
inner = u"({expr})".format(expr=inner)
# Correctly format the name of the function.
if fname == "sqrt":
fname = ur"\sqrt"
elif fname == "log10":
fname = ur"\log_{10}"
elif fname == "log2":
fname = ur"\log_2"
else:
fname = ur"\text{{{fname}}}".format(fname=fname)
# Put it together.
latex = fname + inner
return LatexRendered(latex, tall=children[1].tall)
# Return the function within the closure.
return render_function
def render_power(children):
"""
Combine powers so that the latex is wrapped in curly braces correctly.
Also, if you have 'a^(b+c)' don't include that last set of parens:
'a^{b+c}' is correct, whereas 'a^{(b+c)}' is extraneous.
"""
if len(children) == 1:
return children[0]
children_latex = [k.latex for k in children if k.latex != "^"]
children_latex[-1] = children[-1].sans_parens
raise_power = lambda x, y: u"{}^{{{}}}".format(y, x)
latex = reduce(raise_power, reversed(children_latex))
return LatexRendered(latex, tall=True)
def render_parallel(children):
"""
Simply join the child nodes with a double vertical line.
"""
if len(children) == 1:
return children[0]
children_latex = [k.latex for k in children if k.latex != "||"]
latex = r"\|".join(children_latex)
tall = any(k.tall for k in children)
return LatexRendered(latex, tall=tall)
def render_frac(numerator, denominator):
r"""
Given a list of elements in the numerator and denominator, return a '\frac'
Avoid parens if they are unnecessary (i.e. the only thing in that part).
"""
if len(numerator) == 1:
num_latex = numerator[0].sans_parens
else:
num_latex = r"\cdot ".join(k.latex for k in numerator)
if len(denominator) == 1:
den_latex = denominator[0].sans_parens
else:
den_latex = r"\cdot ".join(k.latex for k in denominator)
latex = ur"\frac{{{num}}}{{{den}}}".format(num=num_latex, den=den_latex)
return latex
def render_product(children):
r"""
Format products and division nicely.
Group bunches of adjacent, equal operators. Every time it switches from
denominator to the next numerator, call `render_frac`. Join these groupings
together with '\cdot's, ending on a numerator if needed.
Examples: (`children` is formed indirectly by the string on the left)
'a*b' -> 'a\cdot b'
'a/b' -> '\frac{a}{b}'
'a*b/c/d' -> '\frac{a\cdot b}{c\cdot d}'
'a/b*c/d*e' -> '\frac{a}{b}\cdot \frac{c}{d}\cdot e'
"""
if len(children) == 1:
return children[0]
position = "numerator" # or denominator
fraction_mode_ever = False
numerator = []
denominator = []
latex = ""
for kid in children:
if position == "numerator":
if kid.latex == "*":
pass # Don't explicitly add the '\cdot' yet.
elif kid.latex == "/":
# Switch to denominator mode.
fraction_mode_ever = True
position = "denominator"
else:
numerator.append(kid)
else:
if kid.latex == "*":
# Switch back to numerator mode.
# First, render the current fraction and add it to the latex.
latex += render_frac(numerator, denominator) + r"\cdot "
# Reset back to beginning state
position = "numerator"
numerator = []
denominator = []
elif kid.latex == "/":
pass # Don't explicitly add a '\frac' yet.
else:
denominator.append(kid)
# Add the fraction/numerator that we ended on.
if position == "denominator":
latex += render_frac(numerator, denominator)
else:
# We ended on a numerator--act like normal multiplication.
num_latex = r"\cdot ".join(k.latex for k in numerator)
latex += num_latex
tall = fraction_mode_ever or any(k.tall for k in children)
return LatexRendered(latex, tall=tall)
def render_sum(children):
"""
Concatenate elements, including the operators.
"""
if len(children) == 1:
return children[0]
children_latex = [k.latex for k in children]
latex = "".join(children_latex)
tall = any(k.tall for k in children)
return LatexRendered(latex, tall=tall)
def render_atom(children):
"""
Properly handle parens, otherwise this is trivial.
"""
if len(children) == 3:
return LatexRendered(
children[1].latex,
parens=children[0].latex,
tall=children[1].tall
)
else:
return children[0]
def add_defaults(var, fun, case_sensitive=False):
"""
Create sets with both the default and user-defined variables.
Compare to calc.add_defaults
"""
var_items = set(DEFAULT_VARIABLES)
fun_items = set(DEFAULT_FUNCTIONS)
var_items.update(var)
fun_items.update(fun)
if not case_sensitive:
var_items = set(k.lower() for k in var_items)
fun_items = set(k.lower() for k in fun_items)
return var_items, fun_items
def latex_preview(math_expr, variables=(), functions=(), case_sensitive=False):
"""
Convert `math_expr` into latex, guaranteeing its parse-ability.
Analagous to `evaluator`.
"""
# No need to go further
if math_expr.strip() == "":
return ""
# Parse tree
latex_interpreter = ParseAugmenter(math_expr, case_sensitive)
latex_interpreter.parse_algebra()
# Get our variables together.
variables, functions = add_defaults(variables, functions, case_sensitive)
# Create a recursion to evaluate the tree.
if case_sensitive:
casify = lambda x: x
else:
casify = lambda x: x.lower() # Lowercase for case insens.
render_actions = {
'number': render_number,
'variable': variable_closure(variables, casify),
'function': function_closure(functions, casify),
'atom': render_atom,
'power': render_power,
'parallel': render_parallel,
'product': render_product,
'sum': render_sum
}
backslash = "\\"
wrap_escaped_strings = lambda s: LatexRendered(
s.replace(backslash, backslash * 2)
)
output = latex_interpreter.reduce_tree(
render_actions,
terminal_converter=wrap_escaped_strings
)
return output.latex
...@@ -14,7 +14,7 @@ class EvaluatorTest(unittest.TestCase): ...@@ -14,7 +14,7 @@ class EvaluatorTest(unittest.TestCase):
Go through all functionalities as specifically as possible-- Go through all functionalities as specifically as possible--
work from number input to functions and complex expressions work from number input to functions and complex expressions
Also test custom variable substitutions (i.e. Also test custom variable substitutions (i.e.
`evaluator({'x':3.0},{}, '3*x')` `evaluator({'x':3.0}, {}, '3*x')`
gives 9.0) and more. gives 9.0) and more.
""" """
...@@ -41,37 +41,40 @@ class EvaluatorTest(unittest.TestCase): ...@@ -41,37 +41,40 @@ class EvaluatorTest(unittest.TestCase):
""" """
The string '.' should not evaluate to anything. The string '.' should not evaluate to anything.
""" """
self.assertRaises(ParseException, calc.evaluator, {}, {}, '.') with self.assertRaises(ParseException):
self.assertRaises(ParseException, calc.evaluator, {}, {}, '1+.') calc.evaluator({}, {}, '.')
with self.assertRaises(ParseException):
calc.evaluator({}, {}, '1+.')
def test_trailing_period(self): def test_trailing_period(self):
""" """
Test that things like '4.' will be 4 and not throw an error Test that things like '4.' will be 4 and not throw an error
""" """
try: self.assertEqual(4.0, calc.evaluator({}, {}, '4.'))
self.assertEqual(4.0, calc.evaluator({}, {}, '4.'))
except ParseException:
self.fail("'4.' is a valid input, but threw an exception")
def test_exponential_answer(self): def test_exponential_answer(self):
""" """
Test for correct interpretation of scientific notation Test for correct interpretation of scientific notation
""" """
answer = 50 answer = 50
correct_responses = ["50", "50.0", "5e1", "5e+1", correct_responses = [
"50e0", "50.0e0", "500e-1"] "50", "50.0", "5e1", "5e+1",
"50e0", "50.0e0", "500e-1"
]
incorrect_responses = ["", "3.9", "4.1", "0", "5.01e1"] incorrect_responses = ["", "3.9", "4.1", "0", "5.01e1"]
for input_str in correct_responses: for input_str in correct_responses:
result = calc.evaluator({}, {}, input_str) result = calc.evaluator({}, {}, input_str)
fail_msg = "Expected '{0}' to equal {1}".format( fail_msg = "Expected '{0}' to equal {1}".format(
input_str, answer) input_str, answer
)
self.assertEqual(answer, result, msg=fail_msg) self.assertEqual(answer, result, msg=fail_msg)
for input_str in incorrect_responses: for input_str in incorrect_responses:
result = calc.evaluator({}, {}, input_str) result = calc.evaluator({}, {}, input_str)
fail_msg = "Expected '{0}' to not equal {1}".format( fail_msg = "Expected '{0}' to not equal {1}".format(
input_str, answer) input_str, answer
)
self.assertNotEqual(answer, result, msg=fail_msg) self.assertNotEqual(answer, result, msg=fail_msg)
def test_si_suffix(self): def test_si_suffix(self):
...@@ -80,17 +83,21 @@ class EvaluatorTest(unittest.TestCase): ...@@ -80,17 +83,21 @@ class EvaluatorTest(unittest.TestCase):
For instance 'k' stand for 'kilo-' so '1k' should be 1,000 For instance 'k' stand for 'kilo-' so '1k' should be 1,000
""" """
test_mapping = [('4.2%', 0.042), ('2.25k', 2250), ('8.3M', 8300000), test_mapping = [
('9.9G', 9.9e9), ('1.2T', 1.2e12), ('7.4c', 0.074), ('4.2%', 0.042), ('2.25k', 2250), ('8.3M', 8300000),
('5.4m', 0.0054), ('8.7u', 0.0000087), ('9.9G', 9.9e9), ('1.2T', 1.2e12), ('7.4c', 0.074),
('5.6n', 5.6e-9), ('4.2p', 4.2e-12)] ('5.4m', 0.0054), ('8.7u', 0.0000087),
('5.6n', 5.6e-9), ('4.2p', 4.2e-12)
]
for (expr, answer) in test_mapping: for (expr, answer) in test_mapping:
tolerance = answer * 1e-6 # Make rel. tolerance, because of floats tolerance = answer * 1e-6 # Make rel. tolerance, because of floats
fail_msg = "Failure in testing suffix '{0}': '{1}' was not {2}" fail_msg = "Failure in testing suffix '{0}': '{1}' was not {2}"
fail_msg = fail_msg.format(expr[-1], expr, answer) fail_msg = fail_msg.format(expr[-1], expr, answer)
self.assertAlmostEqual(calc.evaluator({}, {}, expr), answer, self.assertAlmostEqual(
delta=tolerance, msg=fail_msg) calc.evaluator({}, {}, expr), answer,
delta=tolerance, msg=fail_msg
)
def test_operator_sanity(self): def test_operator_sanity(self):
""" """
...@@ -104,19 +111,20 @@ class EvaluatorTest(unittest.TestCase): ...@@ -104,19 +111,20 @@ class EvaluatorTest(unittest.TestCase):
input_str = "{0} {1} {2}".format(var1, operator, var2) input_str = "{0} {1} {2}".format(var1, operator, var2)
result = calc.evaluator({}, {}, input_str) result = calc.evaluator({}, {}, input_str)
fail_msg = "Failed on operator '{0}': '{1}' was not {2}".format( fail_msg = "Failed on operator '{0}': '{1}' was not {2}".format(
operator, input_str, answer) operator, input_str, answer
)
self.assertEqual(answer, result, msg=fail_msg) self.assertEqual(answer, result, msg=fail_msg)
def test_raises_zero_division_err(self): def test_raises_zero_division_err(self):
""" """
Ensure division by zero gives an error Ensure division by zero gives an error
""" """
self.assertRaises(ZeroDivisionError, calc.evaluator, with self.assertRaises(ZeroDivisionError):
{}, {}, '1/0') calc.evaluator({}, {}, '1/0')
self.assertRaises(ZeroDivisionError, calc.evaluator, with self.assertRaises(ZeroDivisionError):
{}, {}, '1/0.0') calc.evaluator({}, {}, '1/0.0')
self.assertRaises(ZeroDivisionError, calc.evaluator, with self.assertRaises(ZeroDivisionError):
{'x': 0.0}, {}, '1/x') calc.evaluator({'x': 0.0}, {}, '1/x')
def test_parallel_resistors(self): def test_parallel_resistors(self):
""" """
...@@ -153,7 +161,8 @@ class EvaluatorTest(unittest.TestCase): ...@@ -153,7 +161,8 @@ class EvaluatorTest(unittest.TestCase):
input_str = "{0}({1})".format(fname, arg) input_str = "{0}({1})".format(fname, arg)
result = calc.evaluator({}, {}, input_str) result = calc.evaluator({}, {}, input_str)
fail_msg = "Failed on function {0}: '{1}' was not {2}".format( fail_msg = "Failed on function {0}: '{1}' was not {2}".format(
fname, input_str, val) fname, input_str, val
)
self.assertAlmostEqual(val, result, delta=tolerance, msg=fail_msg) self.assertAlmostEqual(val, result, delta=tolerance, msg=fail_msg)
def test_trig_functions(self): def test_trig_functions(self):
...@@ -303,21 +312,29 @@ class EvaluatorTest(unittest.TestCase): ...@@ -303,21 +312,29 @@ class EvaluatorTest(unittest.TestCase):
""" """
# Test sqrt # Test sqrt
self.assert_function_values('sqrt', self.assert_function_values(
[0, 1, 2, 1024], # -1 'sqrt',
[0, 1, 1.414, 32]) # 1j [0, 1, 2, 1024], # -1
[0, 1, 1.414, 32] # 1j
)
# sqrt(-1) is NAN not j (!!). # sqrt(-1) is NAN not j (!!).
# Test logs # Test logs
self.assert_function_values('log10', self.assert_function_values(
[0.1, 1, 3.162, 1000000, '1+j'], 'log10',
[-1, 0, 0.5, 6, 0.151 + 0.341j]) [0.1, 1, 3.162, 1000000, '1+j'],
self.assert_function_values('log2', [-1, 0, 0.5, 6, 0.151 + 0.341j]
[0.5, 1, 1.414, 1024, '1+j'], )
[-1, 0, 0.5, 10, 0.5 + 1.133j]) self.assert_function_values(
self.assert_function_values('ln', 'log2',
[0.368, 1, 1.649, 2.718, 42, '1+j'], [0.5, 1, 1.414, 1024, '1+j'],
[-1, 0, 0.5, 1, 3.738, 0.347 + 0.785j]) [-1, 0, 0.5, 10, 0.5 + 1.133j]
)
self.assert_function_values(
'ln',
[0.368, 1, 1.649, 2.718, 42, '1+j'],
[-1, 0, 0.5, 1, 3.738, 0.347 + 0.785j]
)
# Test abs # Test abs
self.assert_function_values('abs', [-1, 0, 1, 'j'], [1, 0, 1, 1]) self.assert_function_values('abs', [-1, 0, 1, 'j'], [1, 0, 1, 1])
...@@ -341,26 +358,28 @@ class EvaluatorTest(unittest.TestCase): ...@@ -341,26 +358,28 @@ class EvaluatorTest(unittest.TestCase):
""" """
# Of the form ('expr', python value, tolerance (or None for exact)) # Of the form ('expr', python value, tolerance (or None for exact))
default_variables = [('j', 1j, None), default_variables = [
('e', 2.7183, 1e-3), ('i', 1j, None),
('pi', 3.1416, 1e-3), ('j', 1j, None),
# c = speed of light ('e', 2.7183, 1e-4),
('c', 2.998e8, 1e5), ('pi', 3.1416, 1e-4),
# 0 deg C = T Kelvin ('k', 1.3806488e-23, 1e-26), # Boltzmann constant (Joules/Kelvin)
('T', 298.15, 0.01), ('c', 2.998e8, 1e5), # Light Speed in (m/s)
# Note k = scipy.constants.k = 1.3806488e-23 ('T', 298.15, 0.01), # 0 deg C = T Kelvin
('k', 1.3806488e-23, 1e-26), ('q', 1.602176565e-19, 1e-22) # Fund. Charge (Coulombs)
# Note q = scipy.constants.e = 1.602176565e-19 ]
('q', 1.602176565e-19, 1e-22)]
for (variable, value, tolerance) in default_variables: for (variable, value, tolerance) in default_variables:
fail_msg = "Failed on constant '{0}', not within bounds".format( fail_msg = "Failed on constant '{0}', not within bounds".format(
variable) variable
)
result = calc.evaluator({}, {}, variable) result = calc.evaluator({}, {}, variable)
if tolerance is None: if tolerance is None:
self.assertEqual(value, result, msg=fail_msg) self.assertEqual(value, result, msg=fail_msg)
else: else:
self.assertAlmostEqual(value, result, self.assertAlmostEqual(
delta=tolerance, msg=fail_msg) value, result,
delta=tolerance, msg=fail_msg
)
def test_complex_expression(self): def test_complex_expression(self):
""" """
...@@ -370,21 +389,51 @@ class EvaluatorTest(unittest.TestCase): ...@@ -370,21 +389,51 @@ class EvaluatorTest(unittest.TestCase):
self.assertAlmostEqual( self.assertAlmostEqual(
calc.evaluator({}, {}, "(2^2+1.0)/sqrt(5e0)*5-1"), calc.evaluator({}, {}, "(2^2+1.0)/sqrt(5e0)*5-1"),
10.180, 10.180,
delta=1e-3) delta=1e-3
)
self.assertAlmostEqual( self.assertAlmostEqual(
calc.evaluator({}, {}, "1+1/(1+1/(1+1/(1+1)))"), calc.evaluator({}, {}, "1+1/(1+1/(1+1/(1+1)))"),
1.6, 1.6,
delta=1e-3) delta=1e-3
)
self.assertAlmostEqual( self.assertAlmostEqual(
calc.evaluator({}, {}, "10||sin(7+5)"), calc.evaluator({}, {}, "10||sin(7+5)"),
-0.567, delta=0.01) -0.567, delta=0.01
self.assertAlmostEqual(calc.evaluator({}, {}, "sin(e)"), )
0.41, delta=0.01) self.assertAlmostEqual(
self.assertAlmostEqual(calc.evaluator({}, {}, "k*T/q"), calc.evaluator({}, {}, "sin(e)"),
0.025, delta=1e-3) 0.41, delta=0.01
self.assertAlmostEqual(calc.evaluator({}, {}, "e^(j*pi)"), )
-1, delta=1e-5) self.assertAlmostEqual(
calc.evaluator({}, {}, "k*T/q"),
0.025, delta=1e-3
)
self.assertAlmostEqual(
calc.evaluator({}, {}, "e^(j*pi)"),
-1, delta=1e-5
)
def test_explicit_sci_notation(self):
"""
Expressions like 1.6*10^-3 (not 1.6e-3) it should evaluate.
"""
self.assertEqual(
calc.evaluator({}, {}, "-1.6*10^-3"),
-0.0016
)
self.assertEqual(
calc.evaluator({}, {}, "-1.6*10^(-3)"),
-0.0016
)
self.assertEqual(
calc.evaluator({}, {}, "-1.6*10^3"),
-1600
)
self.assertEqual(
calc.evaluator({}, {}, "-1.6*10^(3)"),
-1600
)
def test_simple_vars(self): def test_simple_vars(self):
""" """
...@@ -404,19 +453,24 @@ class EvaluatorTest(unittest.TestCase): ...@@ -404,19 +453,24 @@ class EvaluatorTest(unittest.TestCase):
self.assertEqual(calc.evaluator(variables, {}, 'loooooong'), 6.4) self.assertEqual(calc.evaluator(variables, {}, 'loooooong'), 6.4)
# Test a simple equation # Test a simple equation
self.assertAlmostEqual(calc.evaluator(variables, {}, '3*x-y'), self.assertAlmostEqual(
21.25, delta=0.01) # = 3 * 9.72 - 7.91 calc.evaluator(variables, {}, '3*x-y'),
self.assertAlmostEqual(calc.evaluator(variables, {}, 'x*y'), 21.25, delta=0.01 # = 3 * 9.72 - 7.91
76.89, delta=0.01) )
self.assertAlmostEqual(
calc.evaluator(variables, {}, 'x*y'),
76.89, delta=0.01
)
self.assertEqual(calc.evaluator({'x': 9.72, 'y': 7.91}, {}, "13"), 13) self.assertEqual(calc.evaluator({'x': 9.72, 'y': 7.91}, {}, "13"), 13)
self.assertEqual(calc.evaluator(variables, {}, "13"), 13) self.assertEqual(calc.evaluator(variables, {}, "13"), 13)
self.assertEqual( self.assertEqual(
calc.evaluator({ calc.evaluator(
'a': 2.2997471478310274, 'k': 9, 'm': 8, {'a': 2.2997471478310274, 'k': 9, 'm': 8, 'x': 0.6600949841121},
'x': 0.66009498411213041}, {}, "5"
{}, "5"), ),
5) 5
)
def test_variable_case_sensitivity(self): def test_variable_case_sensitivity(self):
""" """
...@@ -424,15 +478,21 @@ class EvaluatorTest(unittest.TestCase): ...@@ -424,15 +478,21 @@ class EvaluatorTest(unittest.TestCase):
""" """
self.assertEqual( self.assertEqual(
calc.evaluator({'R1': 2.0, 'R3': 4.0}, {}, "r1*r3"), calc.evaluator({'R1': 2.0, 'R3': 4.0}, {}, "r1*r3"),
8.0) 8.0
)
variables = {'t': 1.0} variables = {'t': 1.0}
self.assertEqual(calc.evaluator(variables, {}, "t"), 1.0) self.assertEqual(calc.evaluator(variables, {}, "t"), 1.0)
self.assertEqual(calc.evaluator(variables, {}, "T"), 1.0) self.assertEqual(calc.evaluator(variables, {}, "T"), 1.0)
self.assertEqual(calc.evaluator(variables, {}, "t", cs=True), 1.0) self.assertEqual(
calc.evaluator(variables, {}, "t", case_sensitive=True),
1.0
)
# Recall 'T' is a default constant, with value 298.15 # Recall 'T' is a default constant, with value 298.15
self.assertAlmostEqual(calc.evaluator(variables, {}, "T", cs=True), self.assertAlmostEqual(
298, delta=0.2) calc.evaluator(variables, {}, "T", case_sensitive=True),
298, delta=0.2
)
def test_simple_funcs(self): def test_simple_funcs(self):
""" """
...@@ -445,22 +505,41 @@ class EvaluatorTest(unittest.TestCase): ...@@ -445,22 +505,41 @@ class EvaluatorTest(unittest.TestCase):
self.assertEqual(calc.evaluator(variables, functions, 'id(x)'), 4.712) self.assertEqual(calc.evaluator(variables, functions, 'id(x)'), 4.712)
functions.update({'f': numpy.sin}) functions.update({'f': numpy.sin})
self.assertAlmostEqual(calc.evaluator(variables, functions, 'f(x)'), self.assertAlmostEqual(
-1, delta=1e-3) calc.evaluator(variables, functions, 'f(x)'),
-1, delta=1e-3
)
def test_function_case_sensitivity(self): def test_function_case_insensitive(self):
""" """
Test the case sensitivity of functions Test case insensitive evaluation
Normal functions with some capitals should be fine
""" """
functions = {'f': lambda x: x, self.assertAlmostEqual(
'F': lambda x: x + 1} -0.28,
# Test case insensitive evaluation calc.evaluator({}, {}, 'SiN(6)', case_sensitive=False),
# Both evaulations should call the same function delta=1e-3
self.assertEqual(calc.evaluator({}, functions, 'f(6)'), )
calc.evaluator({}, functions, 'F(6)'))
# Test case sensitive evaluation def test_function_case_sensitive(self):
self.assertNotEqual(calc.evaluator({}, functions, 'f(6)', cs=True), """
calc.evaluator({}, functions, 'F(6)', cs=True)) Test case sensitive evaluation
Incorrectly capitilized should fail
Also, it should pick the correct version of a function.
"""
with self.assertRaisesRegexp(calc.UndefinedVariable, 'SiN'):
calc.evaluator({}, {}, 'SiN(6)', case_sensitive=True)
# With case sensitive turned on, it should pick the right function
functions = {'f': lambda x: x, 'F': lambda x: x + 1}
self.assertEqual(
calc.evaluator({}, functions, 'f(6)', case_sensitive=True), 6
)
self.assertEqual(
calc.evaluator({}, functions, 'F(6)', case_sensitive=True), 7
)
def test_undefined_vars(self): def test_undefined_vars(self):
""" """
...@@ -468,9 +547,9 @@ class EvaluatorTest(unittest.TestCase): ...@@ -468,9 +547,9 @@ class EvaluatorTest(unittest.TestCase):
""" """
variables = {'R1': 2.0, 'R3': 4.0} variables = {'R1': 2.0, 'R3': 4.0}
self.assertRaises(calc.UndefinedVariable, calc.evaluator, with self.assertRaisesRegexp(calc.UndefinedVariable, 'QWSEKO'):
{}, {}, "5+7 QWSEKO") calc.evaluator({}, {}, "5+7*QWSEKO")
self.assertRaises(calc.UndefinedVariable, calc.evaluator, with self.assertRaisesRegexp(calc.UndefinedVariable, 'r2'):
{'r1': 5}, {}, "r1+r2") calc.evaluator({'r1': 5}, {}, "r1+r2")
self.assertRaises(calc.UndefinedVariable, calc.evaluator, with self.assertRaisesRegexp(calc.UndefinedVariable, 'r1 r3'):
variables, {}, "r1*r3", cs=True) calc.evaluator(variables, {}, "r1*r3", case_sensitive=True)
# -*- coding: utf-8 -*-
"""
Unit tests for preview.py
"""
import unittest
import preview
import pyparsing
class LatexRenderedTest(unittest.TestCase):
"""
Test the initializing code for LatexRendered.
Specifically that it stores the correct data and handles parens well.
"""
def test_simple(self):
"""
Test that the data values are stored without changing.
"""
math = 'x^2'
obj = preview.LatexRendered(math, tall=True)
self.assertEquals(obj.latex, math)
self.assertEquals(obj.sans_parens, math)
self.assertEquals(obj.tall, True)
def _each_parens(self, with_parens, math, parens, tall=False):
"""
Helper method to test the way parens are wrapped.
"""
obj = preview.LatexRendered(math, parens=parens, tall=tall)
self.assertEquals(obj.latex, with_parens)
self.assertEquals(obj.sans_parens, math)
self.assertEquals(obj.tall, tall)
def test_parens(self):
""" Test curvy parens. """
self._each_parens('(x+y)', 'x+y', '(')
def test_brackets(self):
""" Test brackets. """
self._each_parens('[x+y]', 'x+y', '[')
def test_squiggles(self):
""" Test curly braces. """
self._each_parens(r'\{x+y\}', 'x+y', '{')
def test_parens_tall(self):
""" Test curvy parens with the tall parameter. """
self._each_parens(r'\left(x^y\right)', 'x^y', '(', tall=True)
def test_brackets_tall(self):
""" Test brackets, also tall. """
self._each_parens(r'\left[x^y\right]', 'x^y', '[', tall=True)
def test_squiggles_tall(self):
""" Test tall curly braces. """
self._each_parens(r'\left\{x^y\right\}', 'x^y', '{', tall=True)
def test_bad_parens(self):
""" Check that we get an error with invalid parens. """
with self.assertRaisesRegexp(Exception, 'Unknown parenthesis'):
preview.LatexRendered('x^2', parens='not parens')
class LatexPreviewTest(unittest.TestCase):
"""
Run integrative tests for `latex_preview`.
All functionality was tested `RenderMethodsTest`, but see if it combines
all together correctly.
"""
def test_no_input(self):
"""
With no input (including just whitespace), see that no error is thrown.
"""
self.assertEquals('', preview.latex_preview(''))
self.assertEquals('', preview.latex_preview(' '))
self.assertEquals('', preview.latex_preview(' \t '))
def test_number_simple(self):
""" Simple numbers should pass through. """
self.assertEquals(preview.latex_preview('3.1415'), '3.1415')
def test_number_suffix(self):
""" Suffixes should be escaped. """
self.assertEquals(preview.latex_preview('1.618k'), r'1.618\text{k}')
def test_number_sci_notation(self):
""" Numbers with scientific notation should display nicely """
self.assertEquals(
preview.latex_preview('6.0221413E+23'),
r'6.0221413\!\times\!10^{+23}'
)
self.assertEquals(
preview.latex_preview('-6.0221413E+23'),
r'-6.0221413\!\times\!10^{+23}'
)
def test_number_sci_notation_suffix(self):
""" Test numbers with both of these. """
self.assertEquals(
preview.latex_preview('6.0221413E+23k'),
r'6.0221413\!\times\!10^{+23}\text{k}'
)
self.assertEquals(
preview.latex_preview('-6.0221413E+23k'),
r'-6.0221413\!\times\!10^{+23}\text{k}'
)
def test_variable_simple(self):
""" Simple valid variables should pass through. """
self.assertEquals(preview.latex_preview('x', variables=['x']), 'x')
def test_greek(self):
""" Variable names that are greek should be formatted accordingly. """
self.assertEquals(preview.latex_preview('pi'), r'\pi')
def test_variable_subscript(self):
""" Things like 'epsilon_max' should display nicely """
self.assertEquals(
preview.latex_preview('epsilon_max', variables=['epsilon_max']),
r'\epsilon_{max}'
)
def test_function_simple(self):
""" Valid function names should be escaped. """
self.assertEquals(
preview.latex_preview('f(3)', functions=['f']),
r'\text{f}(3)'
)
def test_function_tall(self):
r""" Functions surrounding a tall element should have \left, \right """
self.assertEquals(
preview.latex_preview('f(3^2)', functions=['f']),
r'\text{f}\left(3^{2}\right)'
)
def test_function_sqrt(self):
""" Sqrt function should be handled specially. """
self.assertEquals(preview.latex_preview('sqrt(3)'), r'\sqrt{3}')
def test_function_log10(self):
""" log10 function should be handled specially. """
self.assertEquals(preview.latex_preview('log10(3)'), r'\log_{10}(3)')
def test_function_log2(self):
""" log2 function should be handled specially. """
self.assertEquals(preview.latex_preview('log2(3)'), r'\log_2(3)')
def test_power_simple(self):
""" Powers should wrap the elements with braces correctly. """
self.assertEquals(preview.latex_preview('2^3^4'), '2^{3^{4}}')
def test_power_parens(self):
""" Powers should ignore the parenthesis of the last math. """
self.assertEquals(preview.latex_preview('2^3^(4+5)'), '2^{3^{4+5}}')
def test_parallel(self):
r""" Parallel items should combine with '\|'. """
self.assertEquals(preview.latex_preview('2||3'), r'2\|3')
def test_product_mult_only(self):
r""" Simple products should combine with a '\cdot'. """
self.assertEquals(preview.latex_preview('2*3'), r'2\cdot 3')
def test_product_big_frac(self):
""" Division should combine with '\frac'. """
self.assertEquals(
preview.latex_preview('2*3/4/5'),
r'\frac{2\cdot 3}{4\cdot 5}'
)
def test_product_single_frac(self):
""" Division should ignore parens if they are extraneous. """
self.assertEquals(
preview.latex_preview('(2+3)/(4+5)'),
r'\frac{2+3}{4+5}'
)
def test_product_keep_going(self):
"""
Complex products/quotients should split into many '\frac's when needed.
"""
self.assertEquals(
preview.latex_preview('2/3*4/5*6'),
r'\frac{2}{3}\cdot \frac{4}{5}\cdot 6'
)
def test_sum(self):
""" Sums should combine its elements. """
# Use 'x' as the first term (instead of, say, '1'), so it can't be
# interpreted as a negative number.
self.assertEquals(
preview.latex_preview('-x+2-3+4', variables=['x']),
'-x+2-3+4'
)
def test_sum_tall(self):
""" A complicated expression should not hide the tallness. """
self.assertEquals(
preview.latex_preview('(2+3^2)'),
r'\left(2+3^{2}\right)'
)
def test_complicated(self):
"""
Given complicated input, ensure that exactly the correct string is made.
"""
self.assertEquals(
preview.latex_preview('11*f(x)+x^2*(3||4)/sqrt(pi)'),
r'11\cdot \text{f}(x)+\frac{x^{2}\cdot (3\|4)}{\sqrt{\pi}}'
)
self.assertEquals(
preview.latex_preview('log10(1+3/4/Cos(x^2)*(x+1))',
case_sensitive=True),
(r'\log_{10}\left(1+\frac{3}{4\cdot \text{Cos}\left(x^{2}\right)}'
r'\cdot (x+1)\right)')
)
def test_syntax_errors(self):
"""
Test a lot of math strings that give syntax errors
Rather than have a lot of self.assertRaises, make a loop and keep track
of those that do not throw a `ParseException`, and assert at the end.
"""
bad_math_list = [
'11+',
'11*',
'f((x)',
'sqrt(x^)',
'3f(x)', # Not 3*f(x)
'3|4',
'3|||4'
]
bad_exceptions = {}
for math in bad_math_list:
try:
preview.latex_preview(math)
except pyparsing.ParseException:
pass # This is what we were expecting. (not excepting :P)
except Exception as error: # pragma: no cover
bad_exceptions[math] = error
else: # pragma: no cover
# If there is no exception thrown, this is a problem
bad_exceptions[math] = None
self.assertEquals({}, bad_exceptions)
...@@ -1748,15 +1748,23 @@ class FormulaResponse(LoncapaResponse): ...@@ -1748,15 +1748,23 @@ class FormulaResponse(LoncapaResponse):
student_variables[str(var)] = value student_variables[str(var)] = value
# log.debug('formula: instructor_vars=%s, expected=%s' % # log.debug('formula: instructor_vars=%s, expected=%s' %
# (instructor_variables,expected)) # (instructor_variables,expected))
instructor_result = evaluator(instructor_variables, dict(),
expected, cs=self.case_sensitive) # Call `evaluator` on the instructor's answer and get a number
instructor_result = evaluator(
instructor_variables, dict(),
expected, case_sensitive=self.case_sensitive
)
try: try:
# log.debug('formula: student_vars=%s, given=%s' % # log.debug('formula: student_vars=%s, given=%s' %
# (student_variables,given)) # (student_variables,given))
student_result = evaluator(student_variables,
dict(), # Call `evaluator` on the student's answer; look for exceptions
given, student_result = evaluator(
cs=self.case_sensitive) student_variables,
dict(),
given,
case_sensitive=self.case_sensitive
)
except UndefinedVariable as uv: except UndefinedVariable as uv:
log.debug( log.debug(
'formularesponse: undefined variable in given=%s' % given) 'formularesponse: undefined variable in given=%s' % given)
......
...@@ -71,51 +71,6 @@ class ModelsTest(unittest.TestCase): ...@@ -71,51 +71,6 @@ class ModelsTest(unittest.TestCase):
vc_str = "<class 'xmodule.video_module.VideoDescriptor'>" vc_str = "<class 'xmodule.video_module.VideoDescriptor'>"
self.assertEqual(str(vc), vc_str) self.assertEqual(str(vc), vc_str)
def test_calc(self):
variables = {'R1': 2.0, 'R3': 4.0}
functions = {'sin': numpy.sin, 'cos': numpy.cos}
self.assertTrue(abs(calc.evaluator(variables, functions, "10000||sin(7+5)+0.5356")) < 0.01)
self.assertEqual(calc.evaluator({'R1': 2.0, 'R3': 4.0}, {}, "13"), 13)
self.assertEqual(calc.evaluator(variables, functions, "13"), 13)
self.assertEqual(calc.evaluator({'a': 2.2997471478310274, 'k': 9, 'm': 8, 'x': 0.66009498411213041}, {}, "5"), 5)
self.assertEqual(calc.evaluator({}, {}, "-1"), -1)
self.assertEqual(calc.evaluator({}, {}, "-0.33"), -.33)
self.assertEqual(calc.evaluator({}, {}, "-.33"), -.33)
self.assertEqual(calc.evaluator(variables, functions, "R1*R3"), 8.0)
self.assertTrue(abs(calc.evaluator(variables, functions, "sin(e)-0.41")) < 0.01)
self.assertTrue(abs(calc.evaluator(variables, functions, "k*T/q-0.025")) < 0.001)
self.assertTrue(abs(calc.evaluator(variables, functions, "e^(j*pi)") + 1) < 0.00001)
self.assertTrue(abs(calc.evaluator(variables, functions, "j||1") - 0.5 - 0.5j) < 0.00001)
variables['t'] = 1.0
# Use self.assertAlmostEqual here...
self.assertTrue(abs(calc.evaluator(variables, functions, "t") - 1.0) < 0.00001)
self.assertTrue(abs(calc.evaluator(variables, functions, "T") - 1.0) < 0.00001)
self.assertTrue(abs(calc.evaluator(variables, functions, "t", cs=True) - 1.0) < 0.00001)
self.assertTrue(abs(calc.evaluator(variables, functions, "T", cs=True) - 298) < 0.2)
# Use self.assertRaises here...
exception_happened = False
try:
calc.evaluator({}, {}, "5+7 QWSEKO")
except:
exception_happened = True
self.assertTrue(exception_happened)
try:
calc.evaluator({'r1': 5}, {}, "r1+r2")
except calc.UndefinedVariable:
pass
self.assertEqual(calc.evaluator(variables, functions, "r1*r3"), 8.0)
exception_happened = False
try:
calc.evaluator(variables, functions, "r1*r3", cs=True)
except:
exception_happened = True
self.assertTrue(exception_happened)
class PostData(object): class PostData(object):
"""Class which emulate postdata.""" """Class which emulate postdata."""
def __init__(self, dict_data): def __init__(self, dict_data):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment