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