diff --git a/vectordraw/__init__.py b/vectordraw/__init__.py index 93de7a9..77a4f14 100644 --- a/vectordraw/__init__.py +++ b/vectordraw/__init__.py @@ -1 +1,6 @@ +""" +Top-level package for Vector Drawing XBlock. + +See vectordraw.vectordraw for more information. +""" from .vectordraw import VectorDrawXBlock diff --git a/vectordraw/grader.py b/vectordraw/grader.py index d955306..61ecdef 100644 --- a/vectordraw/grader.py +++ b/vectordraw/grader.py @@ -1,35 +1,61 @@ +""" +This module contains grading logic for Vector Drawing exercises. +""" + +# pylint: disable=invalid-name + import inspect -import json import logging import math -log = logging.getLogger(__name__) +log = logging.getLogger(__name__) # pylint: disable=invalid-name -## Built-in check functions +# Built-in check functions def _errmsg(default_message, check, vectors): + """ + Return error message for `check` targeting a vector from `vectors`. + + If `check` does not define a custom error message, fall back on `default_message`. + """ 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) + 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): + """ + Return error message for `check` targeting `point`. + + If `check` does not define a custom error message, fall back on `default_message`. + """ template = check.get('errmsg', default_message) return template.format(name=check['point'], x=point.x, y=point.y) + def check_presence(check, vectors): + """ + Check if `vectors` contains vector targeted by `check`. + """ 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): + """ + Check if tail of vector targeted by `check` is in correct position. + """ vec = vectors[check['vector']] tolerance = check.get('tolerance', 1.0) expected = check['expected'] @@ -37,7 +63,11 @@ def check_tail(check, vectors): if dist > tolerance: return _errmsg('Vector {name} does not start at correct point.', check, vectors) + def check_tip(check, vectors): + """ + Check if tip of vector targeted by `check` is in correct position. + """ vec = vectors[check['vector']] tolerance = check.get('tolerance', 1.0) expected = check['expected'] @@ -45,38 +75,66 @@ def check_tip(check, vectors): if dist > tolerance: return _errmsg('Vector {name} does not end at correct point.', check, vectors) + def _check_coordinate(check, coord): + """ + Check `coord` against expected value. + """ tolerance = check.get('tolerance', 1.0) expected = check['expected'] return abs(expected - coord) > tolerance + def check_tail_x(check, vectors): + """ + Check if x position of tail of vector targeted by `check` is correct. + """ 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): + """ + Check if y position of tail of vector targeted by `check` is correct. + """ 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): + """ + Check if x position of tip of vector targeted by `check` is correct. + """ 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): + """ + Check if y position of tip of vector targeted by `check` is correct. + """ 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): + """ + Return distance between `expected` and `actual` coordinates. + """ if expected == '_': return 0 else: return expected - actual + def _coords_within_tolerance(vec, expected, tolerance): + """ + Check if distance between coordinates of `vec` and `expected` coordinates is within `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) @@ -84,14 +142,22 @@ def _coords_within_tolerance(vec, expected, tolerance): return False return True + def check_coords(check, vectors): + """ + Check if coordinates of vector targeted by `check` are in correct position. + """ 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): + """ + Check if coordinates of segment targeted by `check` are in correct position. + """ vec = vectors[check['vector']] expected = check['expected'] tolerance = check.get('tolerance', 1.0) @@ -99,13 +165,23 @@ def check_segment_coords(check, vectors): _coords_within_tolerance(vec.opposite(), expected, tolerance)): return _errmsg('Segment {name} coordinates are not correct.', check, vectors) + def check_length(check, vectors): + """ + Check if length of vector targeted by `check` is correct. + """ 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) + return _errmsg( + 'The length of {name} is incorrect. Your length: {length:.1f}', check, vectors + ) + def _angle_within_tolerance(vec, expected, tolerance): + """ + Check if difference between angle of `vec` and `expected` angle is within `tolerance`. + """ # Calculate angle between vec and identity vector with expected angle # using the formula: # angle = acos((A . B) / len(A)*len(B)) @@ -115,14 +191,22 @@ def _angle_within_tolerance(vec, expected, tolerance): angle = math.degrees(math.acos(dot_product / vec.length)) return abs(angle) <= tolerance + def check_angle(check, vectors): + """ + Check if angle of vector targeted by `check` is correct. + """ 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): + """ + Check if angle of segment targeted by `check` is correct. + """ # 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']] @@ -132,24 +216,38 @@ def check_segment_angle(check, vectors): _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. + """ + Return distance between `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): + """ + Check if line targeted by `check` passes through correct points. + """ 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) + return _errmsg( + 'The line {name} does not pass through the correct points.', check, vectors + ) + def check_point_coords(check, points): + """ + Check if coordinates of point targeted by `check` are correct. + """ point = points[check['point']] tolerance = check.get('tolerance', 1.0) expected = check.get('expected') @@ -157,13 +255,17 @@ def check_point_coords(check, points): if dist > tolerance: return _errmsg_point('Point {name} is not at the correct location.', check, point) + class Point(object): + """ Represents a single point on the vector drawing board. """ def __init__(self, x, y): self.x = x self.y = y + class Vector(object): - def __init__(self, name, x1, y1, x2, y2): + """ Represents a single vector on the vector drawing board. """ + def __init__(self, name, x1, y1, x2, y2): # pylint: disable=too-many-arguments self.name = name self.tail = Point(x1, y1) self.tip = Point(x2, y2) @@ -174,9 +276,16 @@ def __init__(self, name, x1, y1, x2, y2): self.angle = angle def opposite(self): + """ + Return new vector with tip and tail swapped. + """ return Vector(self.name, self.tip.x, self.tip.y, self.tail.x, self.tail.y) + class Grader(object): + """ + Implements grading logic for student answers to Vector Drawing exercises. + """ check_registry = { 'presence': check_presence, 'tail': check_tail, @@ -200,6 +309,11 @@ def __init__(self, success_message='Test passed', custom_checks=None): self.check_registry.update(custom_checks) def grade(self, answer): + """ + Check correctness of `answer` by running checks defined for it one by one. + + Short-circuit as soon as a single check fails. + """ check_data = dict( vectors=self._get_vectors(answer), points=self._get_points(answer), @@ -213,7 +327,10 @@ def grade(self, answer): return {'ok': False, 'msg': result} return {'ok': True, 'msg': self.success_message} - def _get_vectors(self, answer): + def _get_vectors(self, answer): # pylint: disable=no-self-use + """ + Turn vector info in `answer` into a dictionary of Vector objects. + """ vectors = {} for name, props in answer['vectors'].iteritems(): tail = props['tail'] @@ -221,5 +338,8 @@ def _get_vectors(self, answer): vectors[name] = Vector(name, tail[0], tail[1], tip[0], tip[1]) return vectors - def _get_points(self, answer): + def _get_points(self, answer): # pylint: disable=no-self-use + """ + Turn point info in `answer` into a dictionary of Point objects. + """ return {name: Point(*coords) for name, coords in answer['points'].iteritems()} diff --git a/vectordraw/vectordraw.py b/vectordraw/vectordraw.py index fed36e0..b34d6bf 100644 --- a/vectordraw/vectordraw.py +++ b/vectordraw/vectordraw.py @@ -13,9 +13,9 @@ from .grader import Grader -loader = ResourceLoader(__name__) +loader = ResourceLoader(__name__) # pylint: disable=invalid-name -log = logging.getLogger(__name__) +log = logging.getLogger(__name__) # pylint: disable=invalid-name class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): @@ -131,7 +131,8 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): display_name="Vectors", help=( "List of vectors to use for the exercise. " - "You must specify it as an array of entries where each entry represents an individual vector." + "You must specify it as an array of entries " + "where each entry represents an individual vector." ), default="[]", multiline_editor=True, @@ -143,7 +144,8 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): display_name="Points", help=( "List of points to be drawn on the board for reference, or to be placed by the student." - "You must specify it as an array of entries where each entry represents an individual point." + "You must specify it as an array of entries " + "where each entry represents an individual point." ), default="[]", multiline_editor=True, @@ -167,7 +169,8 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): display_name="Custom checks", help=( 'List of custom checks to use for grading. ' - 'This is needed when grading is more complex and cannot be defined in terms of "Expected results" only.' + 'This is needed when grading is more complex ' + 'and cannot be defined in terms of "Expected results" only.' ), default="[]", multiline_editor=True, @@ -216,6 +219,9 @@ class VectorDrawXBlock(StudioEditableXBlockMixin, XBlock): @property def settings(self): + """ + Return settings for this exercise. + """ return { 'width': self.width, 'height': self.height, @@ -227,13 +233,16 @@ def settings(self): 'add_vector_label': self.add_vector_label, 'vector_properties_label': self.vector_properties_label, 'background': self.background, - 'vectors': self.vectors_json, - 'points': self.points_json, - 'expected_result': self.expected_result_json + 'vectors': self.get_vectors, + 'points': self.get_points, + 'expected_result': self.get_expected_result } @property def user_state(self): + """ + Return user state, which is a combination of most recent answer and result. + """ user_state = self.answer if self.result: user_state['result'] = self.result @@ -241,6 +250,9 @@ def user_state(self): @property def background(self): + """ + Return information about background to draw for this exercise. + """ return { 'src': self.background_url, 'width': self.background_width, @@ -248,15 +260,25 @@ def background(self): } @property - def vectors_json(self): + def get_vectors(self): + """ + Load info about vectors for this exercise from JSON string specified by course author. + """ return json.loads(self.vectors) @property - def points_json(self): + def get_points(self): + """ + Load info about points for this exercise from JSON string specified by course author. + """ return json.loads(self.points) @property - def expected_result_json(self): + def get_expected_result(self): + """ + Load info about expected result for this exercise + from JSON string specified by course author. + """ return json.loads(self.expected_result) def student_view(self, context=None): @@ -267,14 +289,20 @@ def student_view(self, context=None): context['self'] = self fragment = Fragment() 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_url( + "//cdnjs.cloudflare.com/ajax/libs/font-awesome/4.3.0/css/font-awesome.min.css" + ) fragment.add_css(loader.load_unicode('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(loader.load_unicode("static/js/src/vectordraw.js")) - fragment.initialize_js('VectorDrawXBlock', {"settings": self.settings, "user_state": self.user_state}) + fragment.initialize_js( + 'VectorDrawXBlock', {"settings": self.settings, "user_state": self.user_state} + ) return fragment - def is_valid(self, data): + def is_valid(self, data): # pylint: disable=no-self-use """ Validate answer data submitted by user. """ @@ -308,7 +336,7 @@ def is_valid(self, data): return 'checks' in data @XBlock.json_handler - def check_answer(self, data, suffix=''): + def check_answer(self, data, suffix=''): # pylint: disable=unused-argument """ Check and persist student's answer to this vector drawing problem. """