Commit 688d47fb by Alexander Kryklia

Give numerical response tolerance as a range (for example [5,7)) (BLD-25)

parent 4b6de31c
......@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Blades: Give numerical response tolerance as a range. BLD-25.
Blades: Allow user with BetaTester role correctly use LTI. BLD-641.
Blades: Video player persist speed preferences between videos. BLD-237.
......
......@@ -87,6 +87,7 @@ or= mouse</code></pre>
</div>
<div class="col">
<pre><code>= 3.14 +- 2%</code></pre>
<pre><code>= [3.14, 3.15)</code></pre>
</div>
</div>
<div class="row">
......
......@@ -26,6 +26,8 @@ import subprocess
import textwrap
import traceback
import xml.sax.saxutils as saxutils
from cmath import isnan
from sys import float_info
from collections import namedtuple
from shapely.geometry import Point, MultiPoint
......@@ -36,8 +38,10 @@ from . import correctmap
from .registry import TagRegistry
from datetime import datetime
from pytz import UTC
from .util import (compare_with_tolerance, contextualize_text, convert_files_to_filenames,
is_list_of_files, find_with_default, default_tolerance)
from .util import (
compare_with_tolerance, contextualize_text, convert_files_to_filenames,
is_list_of_files, find_with_default, default_tolerance
)
from lxml import etree
from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME?
import capa.xqueue_interface as xqueue_interface
......@@ -846,39 +850,57 @@ class NumericalResponse(LoncapaResponse):
def __init__(self, *args, **kwargs):
self.correct_answer = ''
self.tolerance = default_tolerance
self.range_tolerance = False
self.answer_range = self.inclusion = None
super(NumericalResponse, self).__init__(*args, **kwargs)
def setup_response(self):
xml = self.xml
context = self.context
self.correct_answer = contextualize_text(xml.get('answer'), context)
answer = xml.get('answer')
# Find the tolerance
tolerance_xml = xml.xpath(
'//*[@id=$id]//responseparam[@type="tolerance"]/@default',
id=xml.get('id')
)
if tolerance_xml: # If it isn't an empty list...
self.tolerance = contextualize_text(tolerance_xml[0], context)
if answer.startswith(('[', '(')) and answer.endswith((']', ')')): # range tolerance case
self.range_tolerance = True
self.inclusion = (
True if answer.startswith('[') else False, True if answer.endswith(']') else False
)
try:
self.answer_range = [contextualize_text(x, context) for x in answer[1:-1].split(',')]
self.correct_answer = answer[0] + self.answer_range[0] + ', ' + self.answer_range[1] + answer[-1]
except Exception:
log.debug("Content error--answer '%s' is not a valid range tolerance answer", answer)
_ = self.capa_system.i18n.ugettext
raise StudentInputError(
_("There was a problem with the staff answer to this problem.")
)
else:
self.correct_answer = contextualize_text(answer, context)
# Find the tolerance
tolerance_xml = xml.xpath(
'//*[@id=$id]//responseparam[@type="tolerance"]/@default',
id=xml.get('id')
)
if tolerance_xml: # If it isn't an empty list...
self.tolerance = contextualize_text(tolerance_xml[0], context)
def get_staff_ans(self):
def get_staff_ans(self, answer):
"""
Given the staff answer as a string, find its float value.
Use `evaluator` for this, but for backward compatability, try the
built-in method `complex` (which used to be the standard).
"""
try:
correct_ans = complex(self.correct_answer)
correct_ans = complex(answer)
except ValueError:
# When `correct_answer` is not of the form X+Yj, it raises a
# `ValueError`. Then test if instead it is a math expression.
# `complex` seems to only generate `ValueErrors`, only catch these.
try:
correct_ans = evaluator({}, {}, self.correct_answer)
correct_ans = evaluator({}, {}, answer)
except Exception:
log.debug("Content error--answer '%s' is not a valid number", self.correct_answer)
log.debug("Content error--answer '%s' is not a valid number", answer)
_ = self.capa_system.i18n.ugettext
raise StudentInputError(
_("There was a problem with the staff answer to this problem.")
......@@ -887,11 +909,11 @@ class NumericalResponse(LoncapaResponse):
return correct_ans
def get_score(self, student_answers):
"""Grade a numeric response"""
"""
Grade a numeric response.
"""
student_answer = student_answers[self.answer_id]
correct_float = self.get_staff_ans()
_ = self.capa_system.i18n.ugettext
general_exception = StudentInputError(
_(u"Could not interpret '{student_answer}' as a number.").format(student_answer=cgi.escape(student_answer))
......@@ -924,10 +946,34 @@ class NumericalResponse(LoncapaResponse):
except Exception:
raise general_exception
# End `evaluator` block -- we figured out the student's answer!
correct = compare_with_tolerance(
student_float, correct_float, self.tolerance
)
if self.range_tolerance:
if isinstance(student_float, complex):
raise StudentInputError(_(u"You may not use complex numbers in range tolerance problems"))
if isnan(student_float):
raise general_exception
boundaries = []
for inclusion, answer in zip(self.inclusion, self.answer_range):
boundary = self.get_staff_ans(answer)
if boundary.imag != 0:
raise StudentInputError(_("There was a problem with the staff answer to this problem: complex boundary."))
if isnan(boundary):
raise StudentInputError(_("There was a problem with the staff answer to this problem: empty boundary."))
boundaries.append(boundary.real)
if compare_with_tolerance(
student_float,
boundary,
tolerance=float_info.epsilon,
relative_tolerance=True
):
correct = inclusion
break
else:
correct = boundaries[0] < student_float < boundaries[1]
else:
correct_float = self.get_staff_ans(self.correct_answer)
correct = compare_with_tolerance(
student_float, correct_float, self.tolerance
)
if correct:
return CorrectMap(self.answer_id, 'correct')
else:
......@@ -1062,7 +1108,7 @@ class StringResponse(LoncapaResponse):
if self.regexp: # regexp match
flags = re.IGNORECASE if self.case_insensitive else 0
try:
regexp = re.compile('^'+ '|'.join(expected) + '$', flags=flags | re.UNICODE)
regexp = re.compile('^' + '|'.join(expected) + '$', flags=flags | re.UNICODE)
result = re.search(regexp, given)
except Exception as err:
msg = '[courseware.capa.responsetypes.stringresponse] error: {}'.format(err.message)
......@@ -1075,7 +1121,6 @@ class StringResponse(LoncapaResponse):
else:
return given in expected
def check_hint_condition(self, hxml_set, student_answers):
given = student_answers[self.answer_id].strip()
hints_to_show = []
......
......@@ -1061,6 +1061,48 @@ class NumericalResponseTest(ResponseTest):
# We blend the line between integration (using evaluator) and exclusively
# unit testing the NumericalResponse (mocking out the evaluator)
# For simple things its not worth the effort.
def test_grade_range_tolerance(self):
problem_setup = [
# [given_asnwer, [list of correct responses], [list of incorrect responses]]
['[5, 7)', ['5', '6', '6.999'], ['4.999', '7']],
['[1.6e-5, 1.9e24)', ['0.000016', '1.6*10^-5', '1.59e24'], ['1.59e-5', '1.9e24', '1.9*10^24']],
['[0, 1.6e-5]', ['1.6*10^-5'], ["2"]],
['(1.6e-5, 10]', ["2"], ['1.6*10^-5']],
]
for given_answer, correct_responses, incorrect_responses in problem_setup:
problem = self.build_problem(answer=given_answer)
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
def test_grade_range_tolerance_exceptions(self):
# no complex number in range tolerance staff answer
problem = self.build_problem(answer='[1j, 5]')
input_dict = {'1_2_1': '3'}
with self.assertRaises(StudentInputError):
problem.grade_answers(input_dict)
# no complex numbers in student ansers to range tolerance problems
problem = self.build_problem(answer='(1, 5)')
input_dict = {'1_2_1': '1*J'}
with self.assertRaises(StudentInputError):
problem.grade_answers(input_dict)
# test isnan variable
problem = self.build_problem(answer='(1, 5)')
input_dict = {'1_2_1': ''}
with self.assertRaises(StudentInputError):
problem.grade_answers(input_dict)
# test invalid range tolerance answer
with self.assertRaises(StudentInputError):
problem = self.build_problem(answer='(1 5)')
# test empty boundaries
problem = self.build_problem(answer='(1, ]')
input_dict = {'1_2_1': '3'}
with self.assertRaises(StudentInputError):
problem.grade_answers(input_dict)
def test_grade_exact(self):
problem = self.build_problem(answer=4)
correct_responses = ["4", "4.0", "4.00"]
......@@ -1084,17 +1126,17 @@ class NumericalResponseTest(ResponseTest):
Default tolerance for all responsetypes is 1e-3%.
"""
problem_setup = [
#[given_asnwer, [list of correct responses], [list of incorrect responses]]
[1, ["1"], ["1.1"],],
[2.0, ["2.0"], ["1.0"],],
[4, ["4.0", "4.00004"], ["4.00005"]],
# [given_answer, [list of correct responses], [list of incorrect responses]]
[1, ["1"], ["1.1"]],
[2.0, ["2.0"], ["1.0"]],
[4, ["4.0", "4.00004"], ["4.00005"]],
[0.00016, ["1.6*10^-4"], [""]],
[0.000016, ["1.6*10^-5"], ["0.000165"]],
[1.9e24, ["1.9*10^24"], ["1.9001*10^24"]],
[2e-15, ["2*10^-15"], [""]],
[3141592653589793238., ["3141592653589793115."], [""]],
[0.1234567, ["0.123456", "0.1234561"], ["0.123451"]],
[1e-5, ["1e-5", "1.0e-5"], ["-1e-5", "2*1e-5"]],
[0.1234567, ["0.123456", "0.1234561"], ["0.123451"]],
[1e-5, ["1e-5", "1.0e-5"], ["-1e-5", "2*1e-5"]],
]
for given_answer, correct_responses, incorrect_responses in problem_setup:
problem = self.build_problem(answer=given_answer)
......
......@@ -7,19 +7,21 @@ from cmath import isinf
default_tolerance = '0.001%'
def compare_with_tolerance(v1, v2, tol=default_tolerance):
def compare_with_tolerance(complex1, complex2, tolerance=default_tolerance, relative_tolerance=False):
"""
Compare v1 to v2 with maximum tolerance tol.
Compare complex1 to complex2 with maximum tolerance tol.
tol is relative if it ends in %; otherwise, it is absolute.
If tolerance is type string, then it is counted as relative if it ends in %; otherwise, it is absolute.
- v1 : student result (float complex number)
- v2 : instructor result (float complex number)
- tol : tolerance (string representing a number)
- complex1 : student result (float complex number)
- complex2 : instructor result (float complex number)
- tolerance : string representing a number or float
- relative_tolerance: bool, used when`tolerance` is float to explicitly use passed tolerance as relative.
Default tolerance of 1e-3% is added to compare two floats for near-equality
(to handle machine representation errors).
It is relative, as the acceptable difference between two floats depends on the magnitude of the floats.
Default tolerance of 1e-3% is added to compare two floats for
near-equality (to handle machine representation errors).
Default tolerance is relative, as the acceptable difference between two
floats depends on the magnitude of the floats.
(http://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/)
Examples:
In [183]: 0.000016 - 1.6*10**-5
......@@ -27,22 +29,23 @@ def compare_with_tolerance(v1, v2, tol=default_tolerance):
In [212]: 1.9e24 - 1.9*10**24
Out[212]: 268435456.0
"""
relative = tol.endswith('%')
if relative:
tolerance_rel = evaluator(dict(), dict(), tol[:-1]) * 0.01
tolerance = tolerance_rel * max(abs(v1), abs(v2))
if relative_tolerance:
tolerance = tolerance * max(abs(complex1), abs(complex2))
elif tolerance.endswith('%'):
tolerance = evaluator(dict(), dict(), tolerance[:-1]) * 0.01
tolerance = tolerance * max(abs(complex1), abs(complex2))
else:
tolerance = evaluator(dict(), dict(), tol)
tolerance = evaluator(dict(), dict(), tolerance)
if isinf(v1) or isinf(v2):
# If an input is infinite, we can end up with `abs(v1-v2)` and
if isinf(complex1) or isinf(complex2):
# If an input is infinite, we can end up with `abs(complex1-complex2)` and
# `tolerance` both equal to infinity. Then, below we would have
# `inf <= inf` which is a fail. Instead, compare directly.
return v1 == v2
return complex1 == complex2
else:
# v1 and v2 are, in general, complex numbers:
# there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()).
return abs(v1 - v2) <= tolerance
return abs(complex1 - complex2) <= tolerance
def contextualize_text(text, context): # private
......
......@@ -105,6 +105,14 @@ describe 'MarkdownEditingDescriptor', ->
Enter the number of fingers on a human hand:
= 5
Range tolerance case
= [6, 7]
= (1, 2)
If first and last symbols are not brackets, or they are not closed, stringresponse will appear.
= (7), 7
= (1+2
[Explanation]
Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.
......@@ -135,6 +143,22 @@ describe 'MarkdownEditingDescriptor', ->
<formulaequationinput />
</numericalresponse>
<p>Range tolerance case</p>
<numericalresponse answer="[6, 7]">
<formulaequationinput />
</numericalresponse>
<numericalresponse answer="(1, 2)">
<formulaequationinput />
</numericalresponse>
<p>If first and last symbols are not brackets, or they are not closed, stringresponse will appear.</p>
<stringresponse answer="(7), 7" type="ci" >
<textline size="20"/>
</stringresponse>
<stringresponse answer="(1+2" type="ci" >
<textline size="20"/>
</stringresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
......
......@@ -23,7 +23,7 @@ task :builddocs, [:type, :quiet] do |t, args|
end
end
desc "Show docs in browser (mac and ubuntu)."
desc "Show docs in browser: dev, author, data."
task :showdocs, [:options] do |t, args|
if args.options == 'dev'
path = "docs/en_us/developers"
......
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