Add in a squareness test

This CL adds a "squareness" test to the firmware test suite.
Essentially, we've seen a few firmwares for touch devices come in
that don't have a squared resolution.  eg: movement is compressed
in x or y.  When this happens if you draw a 45 degree line on the
pad, the height and width will not be exactly the same, and it will
have a distorted angle..

This test has the robot/operator draw a 45 deg line on the pad,
then looks at the angle formed by these points.  It should be right
about 45 if the resolution is correct.

BUG=none
TEST=manually tested.

Change-Id: Id7536c7371418d622684b3b8f67d1d43376d5554
Signed-off-by: Charlie Mooney <charliemooney@chromium.org>
Reviewed-on: https://chromium-review.googlesource.com/326470
diff --git a/gesture_interpreter.py b/gesture_interpreter.py
index 76f9711..6fbd3f0 100644
--- a/gesture_interpreter.py
+++ b/gesture_interpreter.py
@@ -222,6 +222,9 @@
   elif test.name == tests.DRAG_THUMB_EDGE:
     fn = lambda: _PerformThumbEdgeTest(variation, robot, device_spec)
 
+  elif test.name == tests.SQUARE_RESOLUTION:
+    fn = lambda: _PerformSquareResolutionTest(variation, robot, device_spec)
+
   if fn is None:
     print color.Fore.RED + 'Robot unable to perform gesture! Skipping...'
     return None
@@ -336,3 +339,8 @@
                 robot.fingertips[STANDARD_SECONDARY_FINGERTIP]]
   location = LOCATION_COORDINATES[tests.GV.CENTER]
   robot.Drumroll(device_spec, fingertips, location)
+
+def _PerformSquareResolutionTest(variation, robot, device_spec):
+  fingertip = robot.fingertips[STANDARD_FINGERTIP]
+  location = LOCATION_COORDINATES[tests.GV.CENTER]
+  robot.Draw45DegreeLineSegment(device_spec, [fingertip], location)
diff --git a/tests/test_configurations.py b/tests/test_configurations.py
index a0c791c..5a46f9e 100644
--- a/tests/test_configurations.py
+++ b/tests/test_configurations.py
@@ -45,6 +45,7 @@
 FIRST_FINGER_TRACKING_AND_SECOND_FINGER_TAPS = \
         'first_finger_tracking_and_second_finger_taps'
 DRUMROLL = 'drumroll'
+SQUARE_RESOLUTION = 'square_resolution'
 REPEATED_TAPS = 'repeated_taps_20'
 ONE_FINGER_TRACKING_FROM_CENTER = 'one_finger_tracking_from_center'
 
@@ -54,7 +55,7 @@
   PINCH_TO_ZOOM, ONE_FINGER_TAP, TWO_FINGER_TAP, THREE_FINGER_TAP,
   FOUR_FINGER_TAP, FIVE_FINGER_TAP, ONE_FINGER_PHYSICAL_CLICK,
   TWO_FINGER_PHYSICAL_CLICK, STATIONARY_FINGER_NOT_AFFECTED_BY_2ND_FINGER_TAPS,
-  FAT_FINGER_MOVE_WITH_RESTING_FINGER, DRAG_THUMB_EDGE,
+  FAT_FINGER_MOVE_WITH_RESTING_FINGER, DRAG_THUMB_EDGE, SQUARE_RESOLUTION,
   TWO_CLOSE_FINGERS_TRACKING, RESTING_FINGER_PLUS_2ND_FINGER_MOVE,
   TWO_FAT_FINGERS_TRACKING, FIRST_FINGER_TRACKING_AND_SECOND_FINGER_TAPS,
   DRUMROLL, REPEATED_TAPS, ONE_FINGER_TRACKING_FROM_CENTER
@@ -78,12 +79,31 @@
 PINCH_CRITERIA_MM = '>= 15, ~ -5'
 RELAXED_LINEARITY_CRITERIA_MM = '<= 1.5, ~ +3.0'
 STATIONARY_FINGER_CRITERIA_MM = '<= 1.0'
+SQUARENESS_CRITERIA_RATIO = '~= 45.0, ~ +4'
 RELAXED_STATIONARY_FINGER_CRITERIA_MM = '<= 3.0'
 TAP_ACCURACY_CRITERIA_MM = '<= 0.5, ~ +0.5'
 
 
 # Definitions for the test specifics
 tests_dict = {
+  SQUARE_RESOLUTION:
+  Test(
+    name=SQUARE_RESOLUTION,
+    variations=(GV.CENTER,),
+    prompt='Use one finger and a jig to draw ~2cm 45 degree line segment in '
+           'the center of the pad. Make sure it is exactly 45 degrees away '
+           'from the bottom edge of the sensor and keep all lines in the '
+           'center of the pad -- lines drawn near/off edges can cause false '
+           'negatives for this test.',
+    subprompt={
+      GV.CENTER: ('center',),
+    },
+    validators=(
+      FingerCountValidator('== 1'),
+      ReportRateValidator(REPORT_RATE_CRITERIA_HTZ),
+      LineAngleValidator(SQUARENESS_CRITERIA_RATIO),
+    ),
+  ),
   NOISE_STATIONARY:
   Test(
     name=NOISE_STATIONARY,
diff --git a/tests/validator/fuzzy.py b/tests/validator/fuzzy.py
index e0b99b0..9d4c66d 100644
--- a/tests/validator/fuzzy.py
+++ b/tests/validator/fuzzy.py
@@ -90,6 +90,34 @@
       return (0.5 + 0.5 * math.cos((x - self.middle) / self.width_left *
               math.pi))
 
+class FuzzyPiMemberFunction(FuzzyMemberFunctions):
+  """A class provides fuzzy Pi Membership Function.
+  This is implemented as a generalized bell membership function with
+  parameters to make the top of the bell flattened, producing the "pi"
+  shape we're looking for.
+
+  Pi Membership Function:
+      parameters: (target, acceptable_deviation)
+      grade(x) = 1  for x very close to the target
+               = 0  for x farther than acceptable_deviation away from target
+      E.g., FuzzyPiMemberFunction((1.0, 0.2))
+            Usage: when we want the x == 1, but between 0.8 and 1.2 is okay
+                   grade =  1.0,      when x == 1.0
+                         >  0.0,      when 0.8 <= x <= 1.2
+                         ~= 0.0,      when x < 0.8 or x > 1.2
+  """
+
+  def __init__(self, para):
+    super(FuzzyPiMemberFunction, self).__init__(para)
+    self.target, self.acceptable_deviation = self.para_values
+
+  def grade(self, x):
+    """The grading method of the fuzzy membership function."""
+    def gbellmf(x, width, flatness_on_top, center):
+      return 1.0 / (1.0 + (abs((x - center) / width) ** (2 * flatness_on_top)))
+
+    PI_MF_FLATNESS = 4
+    return gbellmf(x, self.acceptable_deviation, PI_MF_FLATNESS, self.target)
 
 class FuzzySMemberFunction(FuzzyMemberFunctions):
   """A class provides fuzzy S Membership Function.
@@ -163,8 +191,8 @@
 
 # Mapping from membership functions to the fuzzy member function classes.
 MF_DICT = {
-  # TODO(josephsih): PI, TRAPEZ, and TRIANGLE functions are to be implemented.
-  # PI_FUNCTION: FuzzyPiMemberFunction,
+  # TODO(josephsih): TRAPEZ, and TRIANGLE functions are to be implemented.
+  PI_FUNCTION: FuzzyPiMemberFunction,
   SINGLETON_FUNCTION: FuzzySingletonMemberFunction,
   S_FUNCTION: FuzzySMemberFunction,
   # TRAPEZ_FUNCTION: FuzzyTrapezMemberFunction,
@@ -203,8 +231,7 @@
     op_value = None
     for c in criteria_list:
       op, value = c.split()
-      # TODO(josephsih): should support and '~=' later.
-      if op in ['<=', '<', '>=', '>', '==']:
+      if op in ['<=', '<', '>=', '>', '==', '~=']:
         primary_op = op
         self.default_mf_name = DEFAULT_MEMBERSHIP_FUNCTION[op]
         op_value = float(value)
@@ -227,6 +254,8 @@
 
     if primary_op in ['<=', '<', '>=', '>']:
       self.value_range = (range_min, range_max)
+    elif primary_op == '~=':
+      self.value_range = (op_value, tolerable_delta[0])
     elif primary_op == '==':
       self.value_range = (range_min, op_value, range_max)
     else:
diff --git a/tests/validator/validators.py b/tests/validator/validators.py
index 3494012..76f7e56 100644
--- a/tests/validator/validators.py
+++ b/tests/validator/validators.py
@@ -148,6 +148,53 @@
     return result
 
 
+class LineAngleValidator(BaseValidator):
+  """ Validator to check the angle formed between a line drawn on the
+  pad, and the bottom edge of the sensor.  This is intended to detect
+  when the resolution is compressed or stretched in one direction.  In
+  such a situation, the angle will be distorted.
+
+  Example:
+      To verify that the angle is close to 45 degrees:
+        FingerCountValidator('~= 45.0, ~ 5')
+  """
+  def __init__(self, criteria_str, mf=None):
+    name = self.__class__.__name__
+    desc = ("What angle does the reported line form with the bottom edge "
+            "of the sensor?  If a device fails this test, it means that "
+            "movement appears to be compressed along x or y, resulting "
+            "in a distorted angle.")
+
+    super(LineAngleValidator, self).__init__(criteria_str, mf, name, desc)
+
+  def Validate(self, snapshots):
+    # Sort the paths by length, to find the main one
+    paths = sorted(self._SeparatePaths(snapshots), key=lambda x: len(x))
+    angle = float('inf')
+    if paths:
+      longest_path = paths[-1]
+      # Segment the path to find the straightest parts (in the middle)
+      start, middle, end = self._SegmentPath(longest_path)
+      xs = [finger.x for finger in middle]
+      ys = [finger.y for finger in middle]
+      x_delta = max(xs) - min(xs)
+      y_delta = max(ys) - min(ys)
+      if x_delta or y_delta:
+        angle = math.degrees(np.arctan2(y_delta, x_delta))
+
+    result = Result()
+    result.name = self.name
+    result.description = self.description
+    result.units = 'degrees'
+    result.criteria = self.criteria_str
+    result.observed = angle
+    result.score = self.fc.mf.grade(result.observed)
+    if not paths:
+      result.error = 'Cannot measure unless at least one moving finger is seen.'
+
+    return result
+
+
 class ReportRateValidator(BaseValidator):
   """ Validator to check the report rate.
 
diff --git a/touchbot/touchbot.py b/touchbot/touchbot.py
index e0008a6..ffb66bf 100644
--- a/touchbot/touchbot.py
+++ b/touchbot/touchbot.py
@@ -1010,3 +1010,42 @@
     # Lift the fingers both up and return the robot to its original speed
     self._MoveBothFingersAbsolute(0, 0)
     self.PopSpeed()
+
+  def Draw45DegreeLineSegment(self, device_spec, fingertips, location):
+    """ Draw a short line segment 45 degrees off from the x axis of
+    the touch device.  This gesture is slightly different than the others,
+    in that it uses real measurements instead of reletive to the dimensions
+    of the sensor.  This is to allow us to test and see if the resolution
+    of the touch device is configured squarely.  ie: if the robot draws
+    what we know is a 45 degree angle, and the touchpad reports it as
+    45, then we know the resolution is configured correctly, otherwise
+    motion is being compressed on one of the axes.
+    """
+    LINE_LENGTH_MM = 10
+    # Prepare for the gesture, by getting the required fingertips
+    self._RequireFingertips(device_spec, fingertips)
+
+    # Compute the positions of the corners of the square
+    center = device_spec.RelativePosToAbsolutePos(location)
+    center.ax_5 = self._SuggestSpacing(fingertips)
+    center = self._CenterForFingertip(fingertips[0], center)
+
+    start = copy.copy(center)
+    start.x -= LINE_LENGTH_MM // 2
+    start.y -= LINE_LENGTH_MM // 2
+
+    end = copy.copy(center)
+    end.x += LINE_LENGTH_MM // 2
+    end.y += LINE_LENGTH_MM // 2
+
+    # Get the robot into position, touching the starting position
+    self.PushSpeed(self.SPEED_MEDIUM)
+    self._SetCartesian(start, finger_distance=start.ax_5)
+    self._ExtendFingerUntilContact(1)
+    self.PopSpeed()
+
+    # Have the robot trace slowly between the computer points
+    self.PushSpeed(self.SPEED_SLOW)
+    self._SetCartesian(end, finger_distance=end.ax_5)
+    self.PopSpeed()
+    self._MoveBothFingersAbsolute(0, 0)