Optofidelity: PWM-proof StateChangeDetector

The state change detector is the most challenging to make work with
PWM artifacts. There are two major issues:
- During the detection of the finger, the PWM causes reflections off
  the robot finger. This is solved by averaging over multiple frames.
- The resulting data is very noisy, but still shows distinct states.
  We apply a low pass filter (which does not add latency to the result,
  since it's using both a-priori and a-posteriori data for averaging.)
  and normalize the states to the average level of each state.

BUG=chromium:536633
TEST=unit tests included. covered by regression tests on the S6 Edege
     and Nexus 6.

Change-Id: I74ddf87d493c6cf512dcee72618ec428b029637e
Reviewed-on: https://chromium-review.googlesource.com/336530
Commit-Ready: Dennis Kempin <denniskempin@chromium.org>
Tested-by: Dennis Kempin <denniskempin@chromium.org>
Reviewed-by: Dennis Kempin <denniskempin@chromium.org>
diff --git a/optofidelity/optofidelity/benchmark/_keyboard_delegate.py b/optofidelity/optofidelity/benchmark/_keyboard_delegate.py
index 83a8b31..e58b24c 100644
--- a/optofidelity/optofidelity/benchmark/_keyboard_delegate.py
+++ b/optofidelity/optofidelity/benchmark/_keyboard_delegate.py
@@ -60,7 +60,9 @@
     trace.RequireEventTypes(LEDEvent, StateChangeEvent)
 
     for segmented_trace in trace.SegmentedByLED():
-      segmented_trace.RequireEventTypes(LEDEvent, StateChangeEvent)
+      segmented_trace.RequireEventTypes(LEDEvent)
+      if not segmented_trace.HasEventTypes(StateChangeEvent):
+        continue
 
       finger_down = segmented_trace.FindStateSwitch(LEDEvent, LEDEvent.STATE_ON)
       finger_up = segmented_trace.FindStateSwitch(LEDEvent, LEDEvent.STATE_OFF)
diff --git a/optofidelity/optofidelity/detection/_state_change_detector.py b/optofidelity/optofidelity/detection/_state_change_detector.py
index 310bbc7..87c85a7 100644
--- a/optofidelity/optofidelity/detection/_state_change_detector.py
+++ b/optofidelity/optofidelity/detection/_state_change_detector.py
@@ -3,12 +3,14 @@
 # found in the LICENSE file.
 import logging
 
+from matplotlib import pyplot
 from safetynet import Optional
+import cv2
 import numpy as np
 import skimage.filters
 
 from optofidelity.util import nputil
-from optofidelity.videoproc import Canvas, Filter, Image, Shape
+from optofidelity.videoproc import Canvas, DebugView, Filter, Image, Shape
 
 from ._calibrated_frame import CalibratedFrame
 from ._detector import Detector
@@ -16,12 +18,18 @@
 
 _log = logging.getLogger(__name__)
 
-
 class StateChangeDetector(Detector):
   """Detects screen switches between two different states."""
 
   NAME = "state_change"
 
+  SETTLED_THRESHOLDS = dict(max_mean_distance=2.0, window_size=20)
+  """Thresholds to identify when a state has settled.
+     (see nputil.FindSettlingIndex)"""
+
+  FINGER_SEARCH_AVERAGING_FILTER = 5
+  """Number of frames to average for search of finger."""
+
   def __init__(self, area_of_interest, reference_frame):
     """This detector requires an area of interest mask and a reference frame.
 
@@ -39,20 +47,37 @@
     self.reference_frame = reference_frame
 
   @classmethod
-  def _FindFingerTouchdown(cls, video_reader, screen_calibration):
-    def GetFrame(frame_num):
-      return CalibratedFrame(video_reader.FrameAt(frame_num), None,
-                             screen_calibration, frame_num)
+  def _FindFingerTouchdown(cls, video_reader, screen_calibration, debug=False):
+    def debug_log(msg, *args):
+      if debug:
+        print msg % args
+      _log.debug(msg, *args)
 
-    def FindBottomMostShape(frame, frame0):
+    def GetFrame(frame_num, moving_average=False):
+      frame = video_reader.FrameAt(frame_num)
+      if moving_average:
+        length = cls.FINGER_SEARCH_AVERAGING_FILTER
+        for i in range(frame_num + 1, frame_num + length):
+          frame += video_reader.FrameAt(i)
+        frame /= length
+      return CalibratedFrame(frame, None, screen_calibration, frame_num)
+
+    def FindBottomMostShape(screen_space_normalized, frame0):
       """Locate the bottommost shape, which should be the robot finger."""
-      cleaned = 1.0 - (frame0.screen_space_normalized -
-                       frame.screen_space_normalized)
+      cleaned = (frame0.screen_space_normalized - screen_space_normalized)
       cleaned = skimage.filters.gaussian_filter(cleaned, 2.0)
-      cleaned = Filter.Truncate(cleaned)
+      cleaned = np.abs(cleaned)
 
-      binary = cleaned < 0.9
+      # Compensate PWM
+      pwm_image = frame0.MeasurePWM(cleaned)
+      cleaned = Filter.Truncate(cleaned - pwm_image)
+
+      if debug:
+        cv2.imshow("FindFingerTouchdown", cleaned)
+
+      binary = cleaned > 0.1
       shapes = list(Shape.Shapes(binary))
+
       if shapes:
         return max(shapes, key=lambda s: s.bottom)
       return None
@@ -68,9 +93,12 @@
         step_size = 1
       for i in range(start_index + step_size, video_reader.num_frames,
                      step_size):
-        frame = GetFrame(i)
-        shape =  FindBottomMostShape(frame, frame0)
-        _log.debug("FindFirstFingerMovement %03d", i)
+        frame = GetFrame(i, moving_average=5)
+        shape =  FindBottomMostShape(frame.screen_space_normalized, frame0)
+        debug_log("FindFirstFingerMovement %03d", i)
+        if debug:
+          cv2.waitKey()
+
         if shape:
           if step_size == 1:
             return i - step_size
@@ -83,17 +111,34 @@
       last_location = 0
       last_change_frame = start_index
       found = False
+      average_length = cls.FINGER_SEARCH_AVERAGING_FILTER
+
+      # Cache of previous images for averaging
+      images = {}
+      for i in range(start_index - average_length, start_index):
+        images[i] = GetFrame(i).screen_space_normalized
 
       for i in range(start_index, video_reader.num_frames):
-        frame = GetFrame(i)
+        frame = GetFrame(i).screen_space_normalized
+        images[i] = np.copy(frame)
+
+        # average previous images to reduce impact of PWM
+        for prev_i in range(i - average_length, i):
+          frame += images[prev_i]
+        frame = frame / average_length
+
+        # Find y-location of finger
         shape = FindBottomMostShape(frame, frame0)
         location = 0
         if shape:
           location = shape.bottom
 
         delta = location - last_location
-        _log.debug("FindFingerTouchdown %03d: location=%d, delta=%d", i,
+        debug_log("FindFingerTouchdown %03d: location=%d, delta=%d", i,
                    location, delta)
+        if debug:
+          cv2.waitKey()
+
         if delta > 1 or location == 0:
           last_location = location
           last_change_frame = i
@@ -106,27 +151,30 @@
           break
       if not found:
         raise Exception("Cannot find minimum finger location")
+
       _log.info("Found finger touching at %03d", last_change_frame)
       return last_change_frame
 
-    frame0 = GetFrame(0)
+    frame0 = GetFrame(0, moving_average=True)
     first_movement_index = FindFirstFingerMovement(0, 100, frame0)
     touchdown_index = FindTouchdown(first_movement_index, frame0)
 
     touchdown_frame = GetFrame(touchdown_index)
-    finger_shape = FindBottomMostShape(touchdown_frame, frame0)
+    finger_shape = FindBottomMostShape(touchdown_frame.screen_space_normalized,
+                                       frame0)
     return finger_shape, touchdown_frame
 
   @classmethod
   def CreateKeyboardPressDetector(cls, video_reader, screen_calibration,
-                                  aoi_size=(50, 35), aoi_offset=(5, 5)):
+                                  aoi_size=(50, 35), aoi_offset=(5, 5),
+                                  debug=False):
     """Returns a StateChangeDetector tuned to detect keyboard state changes.
 
     This method will locate the touchdown of the finger and set up an area of
     interest adjacent to the finger, where the key popup will apprear.
     """
     finger_shape, reference_frame = cls._FindFingerTouchdown(video_reader,
-                                                             screen_calibration)
+        screen_calibration, debug=debug)
     finger_y = finger_shape.bottom + aoi_offset[1]
     finger_x = finger_shape.right + aoi_offset[0]
     (left, right) = (finger_x, finger_x + aoi_size[0])
@@ -141,6 +189,9 @@
                               bottom = finger_y,
                               left = left,
                               right = right)
+    if debug:
+      DebugView(reference=reference_frame.screen_space_normalized, aoi=aoi.mask)
+
     return cls(aoi, reference_frame.screen_space_normalized)
 
   def Preprocess(self, calib_frame, debugger):
@@ -148,10 +199,12 @@
 
     The state change level describes the deviation from the closed state.
     """
-
     cleaned = (self.reference_frame - calib_frame.screen_space_normalized)
     cleaned = skimage.filters.gaussian_filter(cleaned, 2.0)
-    cleaned = Filter.Truncate(cleaned)
+
+    # Compensate PWM
+    pwm_image = calib_frame.MeasurePWM(cleaned)
+    cleaned = Filter.Truncate(np.abs(cleaned - pwm_image))
 
     state_change_level = np.mean(cleaned[self.area_of_interest.mask])
     _log.info("%04d: state_change_level=%.4f", calib_frame.frame_index,
@@ -163,15 +216,32 @@
                                             self.area_of_interest.contour)
     return state_change_level
 
-  def GenerateEvents(self, preprocessed_data):
+  def GenerateEvents(self, preprocessed_data, debug=False):
     """Segments the array of state change levels into discrete state changes."""
-    level = np.asarray(preprocessed_data, np.float)
-    normalized = level / np.max(level)
+    if debug:
+      for data in preprocessed_data:
+        print "%.4f" % data
 
-    for start, end in nputil.FindStateChanges(normalized, settled_threshold=0.1):
+    level = np.asarray(preprocessed_data, np.float)
+    normalized = nputil.NormalizeStates(level)
+    filtered = nputil.LowPass(normalized, 5)
+
+    if debug:
+      pyplot.figure()
+      pyplot.plot(normalized)
+      pyplot.plot(filtered)
+
+    for start, end in nputil.FindStateChanges(normalized,
+          self.SETTLED_THRESHOLDS, self.SETTLED_THRESHOLDS, debug=debug):
       state = (StateChangeEvent.STATE_OPEN if normalized[end] > 0.5
                                           else StateChangeEvent.STATE_CLOSED)
-      yield StateChangeEvent(end, start, state)
+      if debug:
+        pyplot.vlines(start, 0, 1)
+        pyplot.vlines(end, 0, 1)
+      yield StateChangeEvent(end, start + 1, state)
+
+    if debug:
+      pyplot.show()
 
     for i, data in enumerate(normalized):
       if np.isnan(data):
diff --git a/optofidelity/optofidelity/util/nputil.py b/optofidelity/optofidelity/util/nputil.py
index 686f519..defb6b1 100644
--- a/optofidelity/optofidelity/util/nputil.py
+++ b/optofidelity/optofidelity/util/nputil.py
@@ -61,6 +61,14 @@
   return (array - min) / (max - min)
 
 
+def NormalizeStates(array, crossing_threshold=0.5):
+  """Normalize array to 0..1"""
+  pre_norm = Normalize(array)
+  min = np.mean(pre_norm[pre_norm < crossing_threshold])
+  max = np.mean(pre_norm[pre_norm > crossing_threshold])
+  return (pre_norm - min) / (max - min)
+
+
 def LowPass(array, kernel_size=5):
   """Returns low pass filtered array."""
   kernel = np.ones(kernel_size,) / kernel_size
diff --git a/optofidelity/tests/detection/test_state_change_detector.py b/optofidelity/tests/detection/test_state_change_detector.py
index 9758ab4..9d720fa 100644
--- a/optofidelity/tests/detection/test_state_change_detector.py
+++ b/optofidelity/tests/detection/test_state_change_detector.py
@@ -42,12 +42,157 @@
   0.00203486, 0.00364873, 0.00190578, 0.00051891, 0.00053754, 0.00288633
 ]
 
+change_sequence_pwm = [
+  0.0045, 0.0094, 0.0122, 0.0033, 0.0044, 0.0046, 0.0086, 0.0106, 0.0025,
+  0.0035, 0.0037, 0.0093, 0.0101, 0.0029, 0.0032, 0.0035, 0.0077, 0.0105,
+  0.0032, 0.0019, 0.0032, 0.0087, 0.0117, 0.0037, 0.0034, 0.0037, 0.0081,
+  0.0117, 0.0036, 0.0029, 0.0032, 0.0064, 0.0120, 0.0018, 0.0022, 0.0024,
+  0.0065, 0.0103, 0.0025, 0.0028, 0.0027, 0.0057, 0.0119, 0.0023, 0.0016,
+  0.0018, 0.0059, 0.0088, 0.0024, 0.0020, 0.0026, 0.0044, 0.0096, 0.0041,
+  0.0017, 0.0026, 0.0044, 0.0124, 0.0055, 0.0020, 0.0034, 0.0044, 0.0090,
+  0.0057, 0.0016, 0.0025, 0.0057, 0.0119, 0.0079, 0.0040, 0.0055, 0.0054,
+  0.0111, 0.0079, 0.0045, 0.0045, 0.0049, 0.0104, 0.0059, 0.0018, 0.0024,
+  0.0024, 0.0075, 0.0058, 0.0020, 0.0014, 0.0022, 0.0070, 0.0058, 0.0017,
+  0.0021, 0.0033, 0.0097, 0.0116, 0.0038, 0.0040, 0.0042, 0.0102, 0.0110,
+  0.0038, 0.0035, 0.0038, 0.0097, 0.0113, 0.0037, 0.0025, 0.0027, 0.0088,
+  0.0103, 0.0033, 0.0028, 0.0032, 0.0082, 0.0109, 0.0011, 0.0010, 0.0017,
+  0.0055, 0.0083, 0.0005, 0.0015, 0.0021, 0.0057, 0.0077, 0.0009, 0.0013,
+  0.0017, 0.0061, 0.0111, 0.0016, 0.0022, 0.0023, 0.0062, 0.0094, 0.0023,
+  0.0022, 0.0023, 0.0058, 0.0109, 0.0620, 0.1146, 0.1143, 0.1117, 0.1108,
+  0.1125, 0.1220, 0.1215, 0.1186, 0.1176, 0.1164, 0.1225, 0.1223, 0.1199,
+  0.1214, 0.1151, 0.1221, 0.1226, 0.1178, 0.1182, 0.1149, 0.1224, 0.1209,
+  0.1216, 0.1204, 0.1189, 0.1254, 0.1235, 0.1229, 0.1218, 0.1178, 0.1261,
+  0.1237, 0.1236, 0.1207, 0.1169, 0.1252, 0.1211, 0.1222, 0.1197, 0.1178,
+  0.1241, 0.1211, 0.1217, 0.1209, 0.1202, 0.1248, 0.1221, 0.1220, 0.1204,
+  0.1202, 0.1221, 0.1215, 0.1211, 0.1191, 0.1221, 0.1244, 0.1230, 0.1234,
+  0.1227, 0.1215, 0.1230, 0.1225, 0.1234, 0.1214, 0.1212, 0.1176, 0.1196,
+  0.1208, 0.1198, 0.1187, 0.1096, 0.1116, 0.1136, 0.1112, 0.1129, 0.0263,
+  0.0084, 0.0098, 0.0172, 0.0179, 0.0041, 0.0027, 0.0040, 0.0041, 0.0114,
+  0.0026, 0.0026, 0.0027, 0.0039, 0.0090, 0.0020, 0.0031, 0.0035, 0.0030,
+  0.0088, 0.0030, 0.0020, 0.0026, 0.0026, 0.0068, 0.0056, 0.0023, 0.0027,
+]
+
+change_sequence_noisy = [
+  0.0111, 0.0118, 0.0105, 0.0084, 0.0074, 0.0126, 0.0140, 0.0098, 0.0081,
+  0.0090, 0.0134, 0.0158, 0.0104, 0.0091, 0.0100, 0.0150, 0.0135, 0.0099,
+  0.0106, 0.0096, 0.0145, 0.0150, 0.0099, 0.0087, 0.0097, 0.0141, 0.0140,
+  0.0085, 0.0082, 0.0099, 0.0139, 0.0139, 0.0090, 0.0086, 0.0109, 0.0132,
+  0.0126, 0.0082, 0.0089, 0.0106, 0.0156, 0.0141, 0.0088, 0.0095, 0.0108,
+  0.0146, 0.0110, 0.0084, 0.0087, 0.0124, 0.0150, 0.0140, 0.0112, 0.0099,
+  0.0122, 0.0153, 0.0102, 0.0086, 0.0099, 0.0143, 0.0150, 0.0120, 0.0113,
+  0.0095, 0.0142, 0.0152, 0.0101, 0.0096, 0.0099, 0.0153, 0.0133, 0.0082,
+  0.0080, 0.0114, 0.0161, 0.0153, 0.0115, 0.0082, 0.0117, 0.0140, 0.0128,
+  0.0087, 0.0092, 0.0117, 0.0138, 0.0118, 0.0078, 0.0081, 0.0125, 0.0163,
+  0.0125, 0.0095, 0.0086, 0.0119, 0.0138, 0.0107, 0.0096, 0.0099, 0.0162,
+  0.0165, 0.0109, 0.0122, 0.0109, 0.0149, 0.0152, 0.0098, 0.0107, 0.0106,
+  0.0152, 0.0126, 0.0093, 0.0126, 0.0119, 0.0174, 0.0133, 0.0358, 0.0327,
+  0.0314, 0.0389, 0.0397, 0.0329, 0.0298, 0.0317, 0.0394, 0.0399, 0.0324,
+  0.0281, 0.0319, 0.0399, 0.0373, 0.0329, 0.0280, 0.0332, 0.0393, 0.0345,
+  0.0315, 0.0281, 0.0350, 0.0414, 0.0346, 0.0312, 0.0293, 0.0347, 0.0404,
+  0.0326, 0.0305, 0.0284, 0.0358, 0.0386, 0.0311, 0.0342, 0.0280, 0.0362,
+  0.0393, 0.0306, 0.0339, 0.0284, 0.0366, 0.0386, 0.0309, 0.0318, 0.0286,
+  0.0381, 0.0400, 0.0302, 0.0297, 0.0308, 0.0388, 0.0379, 0.0288, 0.0286,
+  0.0315, 0.0394, 0.0370, 0.0287, 0.0268, 0.0320, 0.0400, 0.0352, 0.0273,
+  0.0262, 0.0359, 0.0423, 0.0354, 0.0319, 0.0288, 0.0369, 0.0426, 0.0345,
+  0.0305, 0.0279, 0.0192, 0.0241, 0.0200, 0.0202, 0.0108, 0.0143, 0.0128,
+  0.0089, 0.0097, 0.0095, 0.0141, 0.0150, 0.0114, 0.0090, 0.0103, 0.0131,
+  0.0144, 0.0096, 0.0085, 0.0100, 0.0134, 0.0113, 0.0071, 0.0090, 0.0101,
+  0.0139, 0.0119, 0.0088, 0.0083, 0.0113, 0.0142, 0.0105, 0.0075, 0.0101,
+  0.0117, 0.0159, 0.0115, 0.0081, 0.0101, 0.0126, 0.0166, 0.0107, 0.0105,
+  0.0109, 0.0149, 0.0137, 0.0097, 0.0099, 0.0112, 0.0144, 0.0122, 0.0087,
+]
+
+change_sequence_multilevel = [
+  0.0106, 0.0119, 0.0149, 0.0138, 0.0132, 0.0115, 0.0141, 0.0152, 0.0136,
+  0.0129, 0.0116, 0.0142, 0.0152, 0.0136, 0.0121, 0.0113, 0.0134, 0.0132,
+  0.0128, 0.0119, 0.0110, 0.0142, 0.0150, 0.0133, 0.0128, 0.0122, 0.0144,
+  0.0127, 0.0138, 0.0150, 0.0123, 0.0145, 0.0126, 0.0124, 0.0131, 0.0144,
+  0.0156, 0.0132, 0.0118, 0.0137, 0.0119, 0.0129, 0.0134, 0.0123, 0.0137,
+  0.0154, 0.0142, 0.0147, 0.0142, 0.0153, 0.0152, 0.0124, 0.0134, 0.0126,
+  0.0157, 0.0156, 0.0125, 0.0122, 0.0118, 0.0146, 0.0136, 0.0133, 0.0120,
+  0.0128, 0.0150, 0.0123, 0.0118, 0.0110, 0.0126, 0.0151, 0.0146, 0.0120,
+  0.0122, 0.0143, 0.0157, 0.0129, 0.0119, 0.0114, 0.0124, 0.0132, 0.0131,
+  0.0116, 0.0119, 0.0132, 0.0133, 0.0123, 0.0103, 0.0112, 0.0143, 0.0136,
+  0.0135, 0.0116, 0.0130, 0.0156, 0.0146, 0.0149, 0.0127, 0.0145, 0.0148,
+  0.0119, 0.0125, 0.0111, 0.0123, 0.0128, 0.0132, 0.0133, 0.0112, 0.0126,
+  0.0129, 0.0129, 0.0133, 0.0120, 0.0138, 0.0130, 0.0141, 0.0147, 0.0124,
+  0.0158, 0.0127, 0.0131, 0.0152, 0.0140, 0.0150, 0.0127, 0.0118, 0.0144,
+  0.0124, 0.0130, 0.0132, 0.0125, 0.0143, 0.0139, 0.0127, 0.0136, 0.0126,
+  0.0144, 0.0140, 0.0124, 0.0126, 0.0124, 0.0143, 0.0143, 0.0124, 0.0119,
+  0.0119, 0.0138, 0.0133, 0.0127, 0.0118, 0.0125, 0.0158, 0.0134, 0.0121,
+  0.0123, 0.0127, 0.0144, 0.0131, 0.0125, 0.0123, 0.0146, 0.0157, 0.0131,
+  0.0114, 0.0113, 0.0140, 0.0151, 0.0126, 0.0108, 0.0122, 0.0137, 0.0134,
+  0.0116, 0.0098, 0.0111, 0.0150, 0.0138, 0.0135, 0.0114, 0.0130, 0.0142,
+  0.0135, 0.0142, 0.0122, 0.0133, 0.0140, 0.0122, 0.0120, 0.0105, 0.0123,
+  0.0138, 0.0126, 0.0120, 0.0453, 0.0524, 0.0517, 0.0525, 0.0508, 0.0527,
+  0.0584, 0.0537, 0.0518, 0.0545, 0.0544, 0.0599, 0.0557, 0.0517, 0.0545,
+  0.0551, 0.0587, 0.0544, 0.0509, 0.0543, 0.0564, 0.0542, 0.0517, 0.0501,
+  0.0543, 0.0563, 0.0524, 0.0502, 0.0502, 0.0547, 0.0577, 0.0513, 0.0476,
+  0.0514, 0.0542, 0.0573, 0.0496, 0.0474, 0.0520, 0.0559, 0.0584, 0.0472,
+  0.0479, 0.0533, 0.0570, 0.0591, 0.0472, 0.0497, 0.0534, 0.0589, 0.0584,
+  0.0434, 0.0490, 0.0535, 0.0574, 0.0581, 0.0433, 0.0512, 0.0556, 0.0590,
+  0.0610, 0.0447, 0.0523, 0.0561, 0.0582, 0.0602, 0.0458, 0.0527, 0.0576,
+  0.0574, 0.0584, 0.0478, 0.0548, 0.0596, 0.0563, 0.0551, 0.0121, 0.0143,
+  0.0155, 0.0141, 0.0130, 0.0122, 0.0150, 0.0139, 0.0127, 0.0122, 0.0109,
+  0.0133, 0.0137, 0.0131, 0.0136, 0.0118, 0.0139, 0.0122, 0.0130, 0.0135,
+  0.0137, 0.0143, 0.0123, 0.0123, 0.0129, 0.0135, 0.0139, 0.0129, 0.0119,
+  0.0159, 0.0144, 0.0143, 0.0136, 0.0122, 0.0139, 0.0124, 0.0126, 0.0132,
+  0.0125, 0.0154, 0.0143, 0.0123, 0.0129, 0.0122, 0.0149, 0.0138, 0.0133,
+  0.0119, 0.0125, 0.0146, 0.0123, 0.0124, 0.0111, 0.0145, 0.0158, 0.0139,
+  0.0122, 0.0120, 0.0138, 0.0156, 0.0139, 0.0120, 0.0118, 0.0146, 0.0151,
+  0.0125, 0.0115, 0.0108, 0.0136, 0.0123, 0.0127, 0.0105, 0.0120, 0.0142,
+  0.0130, 0.0127, 0.0354, 0.0361, 0.0413, 0.0421, 0.0421, 0.0365, 0.0404,
+  0.0450, 0.0443, 0.0430, 0.0373, 0.0423, 0.0454, 0.0442, 0.0427, 0.0392,
+  0.0453, 0.0453, 0.0451, 0.0440, 0.0401, 0.0463, 0.0439, 0.0415, 0.0416,
+  0.0398, 0.0462, 0.0431, 0.0403, 0.0421, 0.0415, 0.0466, 0.0448, 0.0400,
+  0.0421, 0.0432, 0.0445, 0.0414, 0.0385, 0.0417, 0.0440, 0.0426, 0.0402,
+  0.0387, 0.0413, 0.0442, 0.0418, 0.0382, 0.0385, 0.0417, 0.0453, 0.0416,
+  0.0368, 0.0398, 0.0433, 0.0461, 0.0403, 0.0368, 0.0404, 0.0453, 0.0480,
+  0.0379, 0.0373, 0.0409, 0.0461, 0.0457, 0.0353, 0.0372, 0.0418, 0.0464,
+  0.0458, 0.0344, 0.0375, 0.0424, 0.0465, 0.0474, 0.0121, 0.0127, 0.0152,
+  0.0134, 0.0144, 0.0106, 0.0111, 0.0144, 0.0132, 0.0131, 0.0114, 0.0140,
+  0.0153, 0.0139, 0.0151, 0.0126, 0.0145, 0.0155, 0.0130, 0.0125, 0.0114,
+  0.0140, 0.0145, 0.0130, 0.0128, 0.0114, 0.0127, 0.0131, 0.0128, 0.0125,
+  0.0127, 0.0145, 0.0133, 0.0128, 0.0136, 0.0123, 0.0137, 0.0125, 0.0131,
+  0.0137, 0.0137, 0.0143, 0.0130, 0.0118, 0.0133, 0.0136, 0.0144, 0.0127,
+  0.0119, 0.0141, 0.0129, 0.0133, 0.0142, 0.0122, 0.0140, 0.0136, 0.0124,
+]
+
 class StateChangeDetectorTest(DetectorTest):
   def testEventGeneration(self):
     expected_events = [
-      StateChangeEvent(94, 90, StateChangeEvent.STATE_OPEN),
-      StateChangeEvent(152, 150, StateChangeEvent.STATE_CLOSED),
+      StateChangeEvent(95, 90, StateChangeEvent.STATE_OPEN),
+      StateChangeEvent(156, 150, StateChangeEvent.STATE_CLOSED),
     ]
     self.assertExpectedEventsGenerated(StateChangeDetector(None, None),
                                        change_sequence_1, expected_events,
                                        ignore=AnalogStateEvent)
+
+  def testPWMProofEvents(self):
+    expected_events = [
+      StateChangeEvent(140, 138, StateChangeEvent.STATE_OPEN),
+      StateChangeEvent(216, 215, StateChangeEvent.STATE_CLOSED),
+    ]
+    self.assertExpectedEventsGenerated(StateChangeDetector(None, None),
+                                       change_sequence_pwm, expected_events,
+                                       ignore=AnalogStateEvent)
+
+  def testNoisyEventGeneration(self):
+    expected_events = [
+      StateChangeEvent(115, 115, StateChangeEvent.STATE_OPEN),
+      StateChangeEvent(195, 193, StateChangeEvent.STATE_CLOSED),
+    ]
+    self.assertExpectedEventsGenerated(StateChangeDetector(None, None),
+                                       change_sequence_noisy, expected_events,
+                                       ignore=AnalogStateEvent)
+
+  def testMultilevelEventGeneration(self):
+    expected_events = [
+      StateChangeEvent(193, 192, StateChangeEvent.STATE_OPEN),
+      StateChangeEvent(268, 268, StateChangeEvent.STATE_CLOSED),
+      StateChangeEvent(346, 344, StateChangeEvent.STATE_OPEN),
+      StateChangeEvent(420, 420, StateChangeEvent.STATE_CLOSED),
+    ]
+    self.assertExpectedEventsGenerated(StateChangeDetector(None, None),
+                                       change_sequence_multilevel, expected_events,
+                                       ignore=AnalogStateEvent)
\ No newline at end of file