Commit 5b388f52 by Peter Baratta

Merge pull request #125 from edx/pbaratta/calc-add-trig

Simplify calc.py; add trig/other functions
parents 5b793bb6 ab700e63
...@@ -15,6 +15,8 @@ LMS: Some errors handling Non-ASCII data in XML courses have been fixed. ...@@ -15,6 +15,8 @@ LMS: Some errors handling Non-ASCII data in XML courses have been fixed.
LMS: Add page-load tracking using segment-io (if SEGMENT_IO_LMS_KEY and LMS: Add page-load tracking using segment-io (if SEGMENT_IO_LMS_KEY and
SEGMENT_IO_LMS feature flag is on) SEGMENT_IO_LMS feature flag is on)
Blades: Simplify calc.py (which is used for the Numerical/Formula responses); add trig/other functions.
LMS: Background colors on login, register, and courseware have been corrected LMS: Background colors on login, register, and courseware have been corrected
back to white. back to white.
......
"""
Parser and evaluator for FormulaResponse and NumericalResponse
Uses pyparsing to parse. Main function as of now is evaluator().
"""
import copy import copy
import logging
import math import math
import operator import operator
import re import re
import numpy import numpy
import numbers
import scipy.constants import scipy.constants
import calcfunctions
# have numpy raise errors on functions outside its domain
# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html
numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise'
from pyparsing import Word, alphas, nums, oneOf, Literal from pyparsing import (Word, nums, Literal,
from pyparsing import ZeroOrMore, OneOrMore, StringStart ZeroOrMore, MatchFirst,
from pyparsing import StringEnd, Optional, Forward Optional, Forward,
from pyparsing import CaselessLiteral, Group, StringEnd CaselessLiteral,
from pyparsing import NoMatch, stringEnd, alphanums stringEnd, Suppress, Combine)
default_functions = {'sin': numpy.sin, DEFAULT_FUNCTIONS = {'sin': numpy.sin,
'cos': numpy.cos, 'cos': numpy.cos,
'tan': numpy.tan, 'tan': numpy.tan,
'sec': calcfunctions.sec,
'csc': calcfunctions.csc,
'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,
'arccos': numpy.arccos, 'arccos': numpy.arccos,
'arcsin': numpy.arcsin, 'arcsin': numpy.arcsin,
'arctan': numpy.arctan, 'arctan': numpy.arctan,
'arcsec': calcfunctions.arcsec,
'arccsc': calcfunctions.arccsc,
'arccot': calcfunctions.arccot,
'abs': numpy.abs, 'abs': numpy.abs,
'fact': math.factorial, 'fact': math.factorial,
'factorial': 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 = {'j': numpy.complex(0, 1), DEFAULT_VARIABLES = {'i': numpy.complex(0, 1),
'j': numpy.complex(0, 1),
'e': numpy.e, 'e': numpy.e,
'pi': numpy.pi, 'pi': numpy.pi,
'k': scipy.constants.k, 'k': scipy.constants.k,
...@@ -37,65 +66,166 @@ default_variables = {'j': numpy.complex(0, 1), ...@@ -37,65 +66,166 @@ default_variables = {'j': numpy.complex(0, 1),
'q': scipy.constants.e 'q': scipy.constants.e
} }
log = logging.getLogger("mitx.courseware.capa") # 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): class UndefinedVariable(Exception):
def raiseself(self): """
''' Helper so we can use inside of a lambda ''' Used to indicate the student input of a variable, which was unused by the
raise self instructor.
"""
pass
general_whitespace = re.compile('[^\w]+')
def check_variables(string, variables): def check_variables(string, variables):
'''Confirm the only variables in string are defined. """
Confirm the only variables in string are defined.
Pyparsing uses a left-to-right parser, which makes the more Otherwise, raise an UndefinedVariable containing all bad variables.
elegant approach pretty hopeless.
achar = reduce(lambda a,b:a|b ,map(Literal,alphas)) # Any alphabetic character Pyparsing uses a left-to-right parser, which makes a more
undefined_variable = achar + Word(alphanums) elegant approach pretty hopeless.
undefined_variable.setParseAction(lambda x:UndefinedVariable("".join(x)).raiseself()) """
varnames = varnames | undefined_variable general_whitespace = re.compile('[^\\w]+')
''' # List of all alnums in string
possible_variables = re.split(general_whitespace, string) # List of all alnums in string possible_variables = re.split(general_whitespace, string)
bad_variables = list() bad_variables = []
for v in possible_variables: for var in possible_variables:
if len(v) == 0: if len(var) == 0:
continue continue
if v[0] <= '9' and '0' <= 'v': # Skip things that begin with numbers if var[0].isdigit(): # Skip things that begin with numbers
continue continue
if v not in variables: if var not in variables:
bad_variables.append(v) bad_variables.append(var)
if len(bad_variables) > 0: if len(bad_variables) > 0:
raise UndefinedVariable(' '.join(bad_variables)) raise UndefinedVariable(' '.join(bad_variables))
def lower_dict(input_dict):
"""
takes each key in the dict and makes it lowercase, still mapping to the
same value.
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 parse 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 number_parse_action(parse_result):
"""
Create a float out of its string parts
e.g. [ '7', '.', '13' ] -> [ 7.13 ]
Calls super_float above
"""
return super_float("".join(parse_result))
def exp_parse_action(parse_result):
"""
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
"""
# pyparsing.ParseResults doesn't play well with reverse()
parse_result = reversed(parse_result)
# the result of an exponentiation is called a power
power = reduce(lambda a, b: b ** a, parse_result)
return power
def 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
"""
# convert from pyparsing.ParseResults, which doesn't support '0 in parse_result'
parse_result = parse_result.asList()
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]
return 1. / sum(reciprocals)
def sum_parse_action(parse_result):
"""
Add the inputs
[ 1, '+', 2, '-', 3 ] -> 0
Allow a leading + or -
"""
total = 0.0
current_op = operator.add
for token in parse_result:
if token is '+':
current_op = operator.add
elif token is '-':
current_op = operator.sub
else:
total = current_op(total, token)
return total
def prod_parse_action(parse_result):
"""
Multiply the inputs
[ 1, '*', 2, '/', 3 ] => 0.66
"""
prod = 1.0
current_op = operator.mul
for token in parse_result:
if token is '*':
current_op = operator.mul
elif token is '/':
current_op = operator.truediv
else:
prod = current_op(prod, token)
return prod
def evaluator(variables, functions, string, cs=False): def evaluator(variables, functions, string, cs=False):
''' """
Evaluate an expression. Variables are passed as a dictionary Evaluate an expression. Variables are passed as a dictionary
from string to value. Unary functions are passed as a dictionary from string to value. Unary functions are passed as a dictionary
from string to function. Variables must be floats. from string to function. Variables must be floats.
cs: Case sensitive cs: Case sensitive
TODO: Fix it so we can pass integers and complex numbers in variables dict """
'''
# log.debug("variables: {0}".format(variables))
# log.debug("functions: {0}".format(functions))
# log.debug("string: {0}".format(string))
def lower_dict(d):
return dict([(k.lower(), d[k]) for k in d])
all_variables = copy.copy(default_variables)
all_functions = copy.copy(default_functions)
if not cs:
all_variables = lower_dict(all_variables)
all_functions = lower_dict(all_functions)
all_variables = copy.copy(DEFAULT_VARIABLES)
all_functions = copy.copy(DEFAULT_FUNCTIONS)
all_variables.update(variables) all_variables.update(variables)
all_functions.update(functions) all_functions.update(functions)
...@@ -113,122 +243,59 @@ def evaluator(variables, functions, string, cs=False): ...@@ -113,122 +243,59 @@ def evaluator(variables, functions, string, cs=False):
if string.strip() == "": if string.strip() == "":
return float('nan') return float('nan')
ops = {"^": operator.pow,
"*": operator.mul,
"/": operator.truediv,
"+": operator.add,
"-": operator.sub,
}
# We eliminated extreme ones, 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, # 'P':1e15,'E':1e18,'Z':1e21,'Y':1e24,
'c': 1e-2, 'm': 1e-3, 'u': 1e-6,
'n': 1e-9, 'p': 1e-12} # ,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24}
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 number_parse_action(x): # [ '7' ] -> [ 7 ]
return [super_float("".join(x))]
def exp_parse_action(x): # [ 2 ^ 3 ^ 2 ] -> 512
x = [e for e in x if isinstance(e, numbers.Number)] # Ignore ^
x.reverse()
x = reduce(lambda a, b: b ** a, x)
return x
def parallel(x): # Parallel resistors [ 1 2 ] => 2/3
# convert from pyparsing.ParseResults, which doesn't support '0 in x'
x = list(x)
if len(x) == 1:
return x[0]
if 0 in x:
return float('nan')
x = [1. / e for e in x if isinstance(e, numbers.Number)] # Ignore ||
return 1. / sum(x)
def sum_parse_action(x): # [ 1 + 2 - 3 ] -> 0
total = 0.0
op = ops['+']
for e in x:
if e in set('+-'):
op = ops[e]
else:
total = op(total, e)
return total
def prod_parse_action(x): # [ 1 * 2 / 3 ] => 0.66
prod = 1.0
op = ops['*']
for e in x:
if e in set('*/'):
op = ops[e]
else:
prod = op(prod, e)
return prod
def func_parse_action(x):
return [all_functions[x[0]](x[1])]
# SI suffixes and percent # SI suffixes and percent
number_suffix = reduce(lambda a, b: a | b, map(Literal, suffixes.keys()), NoMatch()) number_suffix = MatchFirst([Literal(k) for k in SUFFIXES.keys()])
(dot, minus, plus, times, div, lpar, rpar, exp) = map(Literal, ".-+*/()^") plus_minus = Literal('+') | Literal('-')
times_div = Literal('*') | Literal('/')
number_part = Word(nums) number_part = Word(nums)
# 0.33 or 7 or .34 or 16. # 0.33 or 7 or .34 or 16.
inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part) 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 # 0.33k or -17
number = (Optional(minus | plus) + inner_number number = (inner_number
+ Optional(CaselessLiteral("E") + Optional((plus | minus)) + number_part) + Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part)
+ Optional(number_suffix)) + Optional(number_suffix))
number = number.setParseAction(number_parse_action) # Convert to number number.setParseAction(number_parse_action) # Convert to number
# Predefine recursive variables # Predefine recursive variables
expr = Forward() expr = Forward()
factor = Forward()
# Handle variables passed in.
def sreduce(f, l): # E.g. if we have {'R':0.5}, we make the substitution.
''' Same as reduce, but handle len 1 and len 0 lists sensibly ''' # We sort the list so that var names (like "e2") match before
if len(l) == 0: # mathematical constants (like "e"). This is kind of a hack.
return NoMatch() all_variables_keys = sorted(all_variables.keys(), key=len, reverse=True)
if len(l) == 1: varnames = MatchFirst([CasedLiteral(k) for k in all_variables_keys])
return l[0] varnames.setParseAction(
return reduce(f, l) lambda x: [all_variables[k] for k in x]
)
# Handle variables passed in. E.g. if we have {'R':0.5}, we make the substitution.
# Special case for no variables because of how we understand PyParsing is put together # if all_variables were empty, then pyparsing wants
if len(all_variables) > 0: # varnames = NoMatch()
# We sort the list so that var names (like "e2") match before # this is not the case, as all_variables contains the defaults
# mathematical constants (like "e"). This is kind of a hack.
all_variables_keys = sorted(all_variables.keys(), key=len, reverse=True)
varnames = sreduce(lambda x, y: x | y, map(lambda x: CasedLiteral(x), all_variables_keys))
varnames.setParseAction(lambda x: map(lambda y: all_variables[y], x))
else:
varnames = NoMatch()
# Same thing for functions. # Same thing for functions.
if len(all_functions) > 0: all_functions_keys = sorted(all_functions.keys(), key=len, reverse=True)
funcnames = sreduce(lambda x, y: x | y, funcnames = MatchFirst([CasedLiteral(k) for k in all_functions_keys])
map(lambda x: CasedLiteral(x), all_functions.keys())) function = funcnames + Suppress("(") + expr + Suppress(")")
function = funcnames + lpar.suppress() + expr + rpar.suppress() function.setParseAction(
function.setParseAction(func_parse_action) lambda x: [all_functions[x[0]](x[1])]
else: )
function = NoMatch()
atom = number | function | varnames | Suppress("(") + expr + Suppress(")")
atom = number | function | varnames | lpar + expr + rpar
factor << (atom + ZeroOrMore(exp + atom)).setParseAction(exp_parse_action) # 7^6 # Do the following in the correct order to preserve order of operation
paritem = factor + ZeroOrMore(Literal('||') + factor) # 5k || 4k pow_term = atom + ZeroOrMore(Suppress("^") + atom)
paritem = paritem.setParseAction(parallel) pow_term.setParseAction(exp_parse_action) # 7^6
term = paritem + ZeroOrMore((times | div) + paritem) # 7 * 5 / 4 - 3 par_term = pow_term + ZeroOrMore(Suppress('||') + pow_term) # 5k || 4k
term = term.setParseAction(prod_parse_action) par_term.setParseAction(parallel)
expr << Optional((plus | minus)) + term + ZeroOrMore((plus | minus) + term) # -5 + 4 - 3 prod_term = par_term + ZeroOrMore(times_div + par_term) # 7 * 5 / 4 - 3
expr = expr.setParseAction(sum_parse_action) 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] return (expr + stringEnd).parseString(string)[0]
"""
Provide the mathematical functions that numpy doesn't.
Specifically, the secant/cosecant/cotangents and their inverses and
hyperbolic counterparts
"""
import numpy
# Normal Trig
def sec(arg):
"""
Secant
"""
return 1 / numpy.cos(arg)
def csc(arg):
"""
Cosecant
"""
return 1 / numpy.sin(arg)
def cot(arg):
"""
Cotangent
"""
return 1 / numpy.tan(arg)
# Inverse Trig
# http://en.wikipedia.org/wiki/Inverse_trigonometric_functions#Relationships_among_the_inverse_trigonometric_functions
def arcsec(val):
"""
Inverse secant
"""
return numpy.arccos(1. / val)
def arccsc(val):
"""
Inverse cosecant
"""
return numpy.arcsin(1. / val)
def arccot(val):
"""
Inverse cotangent
"""
if numpy.real(val) < 0:
return -numpy.pi / 2 - numpy.arctan(val)
else:
return numpy.pi / 2 - numpy.arctan(val)
# Hyperbolic Trig
def sech(arg):
"""
Hyperbolic secant
"""
return 1 / numpy.cosh(arg)
def csch(arg):
"""
Hyperbolic cosecant
"""
return 1 / numpy.sinh(arg)
def coth(arg):
"""
Hyperbolic cotangent
"""
return 1 / numpy.tanh(arg)
# And their inverses
def arcsech(val):
"""
Inverse hyperbolic secant
"""
return numpy.arccosh(1. / val)
def arccsch(val):
"""
Inverse hyperbolic cosecant
"""
return numpy.arcsinh(1. / val)
def arccoth(val):
"""
Inverse hyperbolic cotangent
"""
return numpy.arctanh(1. / val)
...@@ -194,6 +194,105 @@ class EvaluatorTest(unittest.TestCase): ...@@ -194,6 +194,105 @@ class EvaluatorTest(unittest.TestCase):
arctan_angles = arcsin_angles arctan_angles = arcsin_angles
self.assert_function_values('arctan', arctan_inputs, arctan_angles) self.assert_function_values('arctan', arctan_inputs, arctan_angles)
def test_reciprocal_trig_functions(self):
"""
Test the reciprocal trig functions provided in calc.py
which are: sec, csc, cot, arcsec, arccsc, arccot
"""
angles = ['-pi/4', 'pi/6', 'pi/5', '5*pi/4', '9*pi/4', '1 + j']
sec_values = [1.414, 1.155, 1.236, -1.414, 1.414, 0.498 + 0.591j]
csc_values = [-1.414, 2, 1.701, -1.414, 1.414, 0.622 - 0.304j]
cot_values = [-1, 1.732, 1.376, 1, 1, 0.218 - 0.868j]
self.assert_function_values('sec', angles, sec_values)
self.assert_function_values('csc', angles, csc_values)
self.assert_function_values('cot', angles, cot_values)
arcsec_inputs = ['1.1547', '1.2361', '2', '-2', '-1.4142', '0.4983+0.5911*j']
arcsec_angles = [0.524, 0.628, 1.047, 2.094, 2.356, 1 + 1j]
self.assert_function_values('arcsec', arcsec_inputs, arcsec_angles)
arccsc_inputs = ['-1.1547', '-1.4142', '2', '1.7013', '1.1547', '0.6215-0.3039*j']
arccsc_angles = [-1.047, -0.785, 0.524, 0.628, 1.047, 1 + 1j]
self.assert_function_values('arccsc', arccsc_inputs, arccsc_angles)
# Has the same range as arccsc
arccot_inputs = ['-0.5774', '-1', '1.7321', '1.3764', '0.5774', '(0.2176-0.868*j)']
arccot_angles = arccsc_angles
self.assert_function_values('arccot', arccot_inputs, arccot_angles)
def test_hyperbolic_functions(self):
"""
Test the hyperbolic functions
which are: sinh, cosh, tanh, sech, csch, coth
"""
inputs = ['0', '0.5', '1', '2', '1+j']
neg_inputs = ['0', '-0.5', '-1', '-2', '-1-j']
negate = lambda x: [-k for k in x]
# sinh is odd
sinh_vals = [0, 0.521, 1.175, 3.627, 0.635 + 1.298j]
self.assert_function_values('sinh', inputs, sinh_vals)
self.assert_function_values('sinh', neg_inputs, negate(sinh_vals))
# cosh is even - do not negate
cosh_vals = [1, 1.128, 1.543, 3.762, 0.834 + 0.989j]
self.assert_function_values('cosh', inputs, cosh_vals)
self.assert_function_values('cosh', neg_inputs, cosh_vals)
# tanh is odd
tanh_vals = [0, 0.462, 0.762, 0.964, 1.084 + 0.272j]
self.assert_function_values('tanh', inputs, tanh_vals)
self.assert_function_values('tanh', neg_inputs, negate(tanh_vals))
# sech is even - do not negate
sech_vals = [1, 0.887, 0.648, 0.266, 0.498 - 0.591j]
self.assert_function_values('sech', inputs, sech_vals)
self.assert_function_values('sech', neg_inputs, sech_vals)
# the following functions do not have 0 in their domain
inputs = inputs[1:]
neg_inputs = neg_inputs[1:]
# csch is odd
csch_vals = [1.919, 0.851, 0.276, 0.304 - 0.622j]
self.assert_function_values('csch', inputs, csch_vals)
self.assert_function_values('csch', neg_inputs, negate(csch_vals))
# coth is odd
coth_vals = [2.164, 1.313, 1.037, 0.868 - 0.218j]
self.assert_function_values('coth', inputs, coth_vals)
self.assert_function_values('coth', neg_inputs, negate(coth_vals))
def test_hyperbolic_inverses(self):
"""
Test the inverse hyperbolic functions
which are of the form arc[X]h
"""
results = [0, 0.5, 1, 2, 1 + 1j]
sinh_vals = ['0', '0.5211', '1.1752', '3.6269', '0.635+1.2985*j']
self.assert_function_values('arcsinh', sinh_vals, results)
cosh_vals = ['1', '1.1276', '1.5431', '3.7622', '0.8337+0.9889*j']
self.assert_function_values('arccosh', cosh_vals, results)
tanh_vals = ['0', '0.4621', '0.7616', '0.964', '1.0839+0.2718*j']
self.assert_function_values('arctanh', tanh_vals, results)
sech_vals = ['1.0', '0.8868', '0.6481', '0.2658', '0.4983-0.5911*j']
self.assert_function_values('arcsech', sech_vals, results)
results = results[1:]
csch_vals = ['1.919', '0.8509', '0.2757', '0.3039-0.6215*j']
self.assert_function_values('arccsch', csch_vals, results)
coth_vals = ['2.164', '1.313', '1.0373', '0.868-0.2176*j']
self.assert_function_values('arccoth', coth_vals, results)
def test_other_functions(self): def test_other_functions(self):
""" """
Test the non-trig functions provided in calc.py Test the non-trig functions provided in calc.py
......
...@@ -1738,6 +1738,7 @@ class FormulaResponse(LoncapaResponse): ...@@ -1738,6 +1738,7 @@ class FormulaResponse(LoncapaResponse):
student_variables = dict() student_variables = dict()
# ranges give numerical ranges for testing # ranges give numerical ranges for testing
for var in ranges: for var in ranges:
# TODO: allow specified ranges (i.e. integers and complex numbers) for random variables
value = random.uniform(*ranges[var]) value = random.uniform(*ranges[var])
instructor_variables[str(var)] = value instructor_variables[str(var)] = value
student_variables[str(var)] = value student_variables[str(var)] = value
......
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