Commit aa7c1340 by Tim Krones

Add grading functionality.

parent dd06d817
import inspect
import json
import logging
import math
log = logging.getLogger(__name__)
## Built-in check functions
def _errmsg(default_message, check, vectors):
template = check.get('errmsg', default_message)
vec = vectors[check['vector']]
return template.format(name=vec.name,
tail_x=vec.tail.x,
tail_y=vec.tail.y,
tip_x=vec.tip.x,
tip_y=vec.tip.y,
length=vec.length,
angle=vec.angle)
def _errmsg_point(default_message, check, point):
template = check.get('errmsg', default_message)
return template.format(name=check['point'], x=point.x, y=point.y)
def check_presence(check, vectors):
if check['vector'] not in vectors:
errmsg = check.get('errmsg', 'You need to use the {name} vector.')
return errmsg.format(name=check['vector'])
def check_tail(check, vectors):
vec = vectors[check['vector']]
tolerance = check.get('tolerance', 1.0)
expected = check['expected']
dist = math.hypot(expected[0] - vec.tail.x, expected[1] - vec.tail.y)
if dist > tolerance:
return _errmsg('Vector {name} does not start at correct point.', check, vectors)
def check_tip(check, vectors):
vec = vectors[check['vector']]
tolerance = check.get('tolerance', 1.0)
expected = check['expected']
dist = math.hypot(expected[0] - vec.tip.x, expected[1] - vec.tip.y)
if dist > tolerance:
return _errmsg('Vector {name} does not end at correct point.', check, vectors)
def _check_coordinate(check, coord):
tolerance = check.get('tolerance', 1.0)
expected = check['expected']
return abs(expected - coord) > tolerance
def check_tail_x(check, vectors):
vec = vectors[check['vector']]
if _check_coordinate(check, vec.tail.x):
return _errmsg('Vector {name} does not start at correct point.', check, vectors)
def check_tail_y(check, vectors):
vec = vectors[check['vector']]
if _check_coordinate(check, vec.tail.y):
return _errmsg('Vector {name} does not start at correct point.', check, vectors)
def check_tip_x(check, vectors):
vec = vectors[check['vector']]
if _check_coordinate(check, vec.tip.x):
return _errmsg('Vector {name} does not end at correct point.', check, vectors)
def check_tip_y(check, vectors):
vec = vectors[check['vector']]
if _check_coordinate(check, vec.tip.y):
return _errmsg('Vector {name} does not end at correct point.', check, vectors)
def _coord_delta(expected, actual):
if expected == '_':
return 0
else:
return expected - actual
def _coords_within_tolerance(vec, expected, tolerance):
for expected_coords, vec_coords in ([expected[0], vec.tail], [expected[1], vec.tip]):
delta_x = _coord_delta(expected_coords[0], vec_coords.x)
delta_y = _coord_delta(expected_coords[1], vec_coords.y)
if math.hypot(delta_x, delta_y) > tolerance:
return False
return True
def check_coords(check, vectors):
vec = vectors[check['vector']]
expected = check['expected']
tolerance = check.get('tolerance', 1.0)
if not _coords_within_tolerance(vec, expected, tolerance):
return _errmsg('Vector {name} coordinates are not correct.', check, vectors)
def check_segment_coords(check, vectors):
vec = vectors[check['vector']]
expected = check['expected']
tolerance = check.get('tolerance', 1.0)
if not (_coords_within_tolerance(vec, expected, tolerance) or
_coords_within_tolerance(vec.opposite(), expected, tolerance)):
return _errmsg('Segment {name} coordinates are not correct.', check, vectors)
def check_length(check, vectors):
vec = vectors[check['vector']]
tolerance = check.get('tolerance', 1.0)
if abs(vec.length - check['expected']) > tolerance:
return _errmsg('The length of {name} is incorrect. Your length: {length:.1f}', check, vectors)
def _angle_within_tolerance(vec, expected, tolerance):
# Calculate angle between vec and identity vector with expected angle
# using the formula:
# angle = acos((A . B) / len(A)*len(B))
x = vec.tip.x - vec.tail.x
y = vec.tip.y - vec.tail.y
dot_product = x * math.cos(expected) + y * math.sin(expected)
angle = math.degrees(math.acos(dot_product / vec.length))
return abs(angle) <= tolerance
def check_angle(check, vectors):
vec = vectors[check['vector']]
tolerance = check.get('tolerance', 2.0)
expected = math.radians(check['expected'])
if not _angle_within_tolerance(vec, expected, tolerance):
return _errmsg('The angle of {name} is incorrect. Your angle: {angle:.1f}', check, vectors)
def check_segment_angle(check, vectors):
# Segments are not directed, so we must check the angle between the segment and
# the vector that represents it, as well as its opposite vector.
vec = vectors[check['vector']]
tolerance = check.get('tolerance', 2.0)
expected = math.radians(check['expected'])
if not (_angle_within_tolerance(vec, expected, tolerance) or
_angle_within_tolerance(vec.opposite(), expected, tolerance)):
return _errmsg('The angle of {name} is incorrect. Your angle: {angle:.1f}', check, vectors)
def _dist_line_point(line, point):
# Return the distance between the given line and point. The line is passed in as a Vector
# instance, the point as a Point instance.
direction_x = line.tip.x - line.tail.x
direction_y = line.tip.y - line.tail.y
determinant = (point.x - line.tail.x) * direction_y - (point.y - line.tail.y) * direction_x
return abs(determinant) / math.hypot(direction_x, direction_y)
def check_points_on_line(check, vectors):
line = vectors[check['vector']]
tolerance = check.get('tolerance', 1.0)
points = check.get('expected')
for point in points:
point = Point(point[0], point[1])
if _dist_line_point(line, point) > tolerance:
return _errmsg('The line {name} does not pass through the correct points.', check, vectors)
def check_point_coords(check, points):
point = points[check['point']]
tolerance = check.get('tolerance', 1.0)
expected = check.get('expected')
dist = math.hypot(expected[0] - point.x, expected[1] - point.y)
if dist > tolerance:
return _errmsg_point('Point {name} is not at the correct location.', check, point)
class Point(object):
def __init__(self, x, y):
self.x = x
self.y = y
class Vector(object):
def __init__(self, name, x1, y1, x2, y2):
self.name = name
self.tail = Point(x1, y1)
self.tip = Point(x2, y2)
self.length = math.hypot(x2 - x1, y2 - y1)
angle = math.degrees(math.atan2(y2 - y1, x2 - x1))
if angle < 0:
angle += 360
self.angle = angle
def opposite(self):
return Vector(self.name, self.tip.x, self.tip.y, self.tail.x, self.tail.y)
class Grader(object):
check_registry = {
'presence': check_presence,
'tail': check_tail,
'tip': check_tip,
'tail_x': check_tail_x,
'tail_y': check_tail_y,
'tip_x': check_tip_x,
'tip_y': check_tip_y,
'coords': check_coords,
'length': check_length,
'angle': check_angle,
'segment_angle': check_segment_angle,
'segment_coords': check_segment_coords,
'points_on_line': check_points_on_line,
'point_coords': check_point_coords,
}
def __init__(self, success_message='Test passed', custom_checks=None):
self.success_message = success_message
if custom_checks:
self.check_registry.update(custom_checks)
def grade(self, answer):
check_data = dict(
vectors=self._get_vectors(answer),
points=self._get_points(answer),
)
for check in answer['checks']:
check_data['check'] = check
check_fn = self.check_registry[check['check']]
args = [check_data[arg] for arg in inspect.getargspec(check_fn).args]
result = check_fn(*args)
if result:
return {'ok': False, 'msg': result}
return {'ok': True, 'msg': self.success_message}
def cfn(self, e, ans):
answer = json.loads(json.loads(ans)['answer'])
return self.grade(answer)
def _get_vectors(self, answer):
vectors = {}
for name, props in answer['vectors'].iteritems():
tail = props['tail']
tip = props['tip']
vectors[name] = Vector(name, tail[0], tail[1], tip[0], tip[1])
return vectors
def _get_points(self, answer):
return {name: Point(*coords) for name, coords in answer['points'].iteritems()}
/* CSS for VectorDrawXBlock */ /* CSS for VectorDrawXBlock */
.vectordraw_block { .vectordraw_block,
.vectordraw_block #vectordraw {
display: inline-block; display: inline-block;
} }
.vectordraw_block .vectordraw-description { .vectordraw_block .vectordraw-description,
.vectordraw_block #vectordraw,
.vectordraw_block .vectordraw-status {
margin-bottom: 1.5em; margin-bottom: 1.5em;
} }
...@@ -86,3 +89,25 @@ vectordraw_block .menu .controls button.redo { ...@@ -86,3 +89,25 @@ vectordraw_block .menu .controls button.redo {
.vectordraw_block .menu .vector-prop-slope { .vectordraw_block .menu .vector-prop-slope {
display: none; display: none;
} }
.vectordraw_block .action button {
height: 40px;
margin-right: 10px;
font-weight: 600;
text-transform: uppercase;
}
.vectordraw_block .vectordraw-status {
display: inline-block;
width: 100%;
}
.vectordraw_block .checkmark-correct {
font-size: 22pt;
color: #629b2b;
}
.vectordraw_block .checkmark-incorrect {
font-size: 22pt;
color: #ff0000;
}
...@@ -10,4 +10,16 @@ ...@@ -10,4 +10,16 @@
<div id="vectordraw" /> <div id="vectordraw" />
<div class="vectordraw-status">
<span class="correctness icon-2x"></span>
<div class="status-message"></div>
</div>
<div class="action">
<button class="check">
<span class="check-label">Check</span>
<span class="sr"> your answer</span>
</button>
</div>
</div> </div>
...@@ -545,11 +545,75 @@ function VectorDrawXBlock(runtime, element, init_args) { ...@@ -545,11 +545,75 @@ function VectorDrawXBlock(runtime, element, init_args) {
this.board.update(); this.board.update();
}; };
var checkHandlerUrl = runtime.handlerUrl(element, 'check_answers');
var checkXHR;
function getInput(vectordraw) {
var input = vectordraw.getState();
// Transform the expected_result setting into a list of checks.
var checks = [];
_.each(vectordraw.settings.expected_result, function(answer, name) {
var presence_check = {vector: name, check: 'presence'};
if ('presence_errmsg' in answer) {
presence_check.errmsg = answer.presence_errmsg;
}
checks.push(presence_check);
[
'tail', 'tail_x', 'tail_y', 'tip', 'tip_x', 'tip_y', 'coords',
'length', 'angle', 'segment_angle', 'segment_coords', 'points_on_line'
].forEach(function(prop) {
if (prop in answer) {
var check = {vector: name, check: prop, expected: answer[prop]};
if (prop + '_tolerance' in answer) {
check.tolerance = answer[prop + '_tolerance'];
}
if (prop + '_errmsg' in answer) {
check.errmsg = answer[prop + '_errmsg'];
}
checks.push(check);
}
});
});
input.checks = checks.concat(vectordraw.settings.custom_checks);
return input;
}
function updateStatus(data) {
var correctness = $('.correctness', element);
if (data.result.ok) {
correctness.removeClass('checkmark-incorrect fa fa-times');
correctness.addClass('checkmark-correct fa fa-check');
} else {
correctness.removeClass('checkmark-correct fa fa-check');
correctness.addClass('checkmark-incorrect fa fa-times');
}
$('.status-message', element).text(data.result.msg);
}
function checkAnswers(vectordraw) {
if (checkXHR) {
checkXHR.abort();
}
var state = getInput(vectordraw);
checkXHR = $.post(checkHandlerUrl, JSON.stringify(state))
.success(function(response) {
console.log(JSON.stringify(response));
updateStatus(response);
});
}
$(function ($) { $(function ($) {
/* Here's where you'd do things on page load. */ /* Here's where you'd do things on page load. */
var vectordraw = new VectorDraw('vectordraw', init_args); var vectordraw = new VectorDraw('vectordraw', init_args);
$('.action .check', element).on('click', function(e) { checkAnswers(vectordraw); });
}); });
} }
"""TO-DO: Write a description of what this XBlock is.""" """TO-DO: Write a description of what this XBlock is."""
import json import json
import logging
import pkg_resources import pkg_resources
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import Scope, Boolean, Integer, String from xblock.fields import Scope, Boolean, Float, Integer, String
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin from xblockutils.studio_editable import StudioEditableXBlockMixin
from .grader import Grader
loader = ResourceLoader(__name__) loader = ResourceLoader(__name__)
log = logging.getLogger(__name__)
class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock):
""" """
...@@ -179,6 +184,13 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -179,6 +184,13 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock):
scope=Scope.content scope=Scope.content
) )
weight = Float(
display_name="Weight",
default=1,
scope=Scope.settings,
enforce_type=True
)
editable_fields = ( editable_fields = (
'display_name', 'display_name',
'description', 'description',
...@@ -200,6 +212,8 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -200,6 +212,8 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock):
'custom_checks' 'custom_checks'
) )
has_score = True
@property @property
def background(self): def background(self):
return { return {
...@@ -216,6 +230,10 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -216,6 +230,10 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock):
def points_json(self): def points_json(self):
return json.loads(self.points) return json.loads(self.points)
@property
def expected_result_json(self):
return json.loads(self.expected_result)
def resource_string(self, path): def resource_string(self, path):
"""Handy helper for getting resources from our kit.""" """Handy helper for getting resources from our kit."""
data = pkg_resources.resource_string(__name__, path) data = pkg_resources.resource_string(__name__, path)
...@@ -230,6 +248,7 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -230,6 +248,7 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock):
context['self'] = self context['self'] = self
fragment = Fragment() fragment = Fragment()
fragment.add_content(loader.render_template('static/html/vectordraw.html', context)) fragment.add_content(loader.render_template('static/html/vectordraw.html', context))
fragment.add_css_url("//cdnjs.cloudflare.com/ajax/libs/font-awesome/4.3.0/css/font-awesome.min.css")
fragment.add_css(self.resource_string('static/css/vectordraw.css')) fragment.add_css(self.resource_string('static/css/vectordraw.css'))
fragment.add_javascript_url("//cdnjs.cloudflare.com/ajax/libs/jsxgraph/0.98/jsxgraphcore.js") fragment.add_javascript_url("//cdnjs.cloudflare.com/ajax/libs/jsxgraph/0.98/jsxgraphcore.js")
fragment.add_javascript(self.resource_string("static/js/src/vectordraw.js")) fragment.add_javascript(self.resource_string("static/js/src/vectordraw.js"))
...@@ -248,6 +267,7 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -248,6 +267,7 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock):
'background': self.background, 'background': self.background,
'vectors': self.vectors_json, 'vectors': self.vectors_json,
'points': self.points_json, 'points': self.points_json,
'expected_result': self.expected_result_json
} }
) )
return fragment return fragment
...@@ -255,15 +275,20 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -255,15 +275,20 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock):
# TO-DO: change this handler to perform your own actions. You may need more # TO-DO: change this handler to perform your own actions. You may need more
# than one handler, or you may not need any handlers at all. # than one handler, or you may not need any handlers at all.
@XBlock.json_handler @XBlock.json_handler
def increment_count(self, data, suffix=''): def check_answers(self, data, suffix=''):
""" """
An example handler, which increments the data. Check student's answers
""" """
# Just to show data coming in... grader = Grader()
assert data['hello'] == 'world' result = grader.grade(data)
# Publish grade data
self.count += 1 score = 1 if result["ok"] else 0
return {"count": self.count} self.runtime.publish(self, 'grade', dict(value=score, max_value=1))
return {
"message": "Success!",
"data": data,
"result": result,
}
# TO-DO: change this to create the scenarios you'd like to see in the # TO-DO: change this to create the scenarios you'd like to see in the
# workbench while developing your XBlock. # workbench while developing your XBlock.
......
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