def test_estimate_gps(self):
        """Tests that the estimating of the locations via GPS is sane."""
        # I'm not sure how to independently validate these tests for accuracy.
        # The best I can think of is to do some sanity tests.
        heading_d = 0.0
        for coordinates in ((100, 200), (200, 100), (300, 300), (50, 50)):
            location_filter = LocationFilter(
                coordinates[0],
                coordinates[1],
                heading_d
            )

            self.assertEqual(
                location_filter.estimated_location(),
                coordinates
            )

            new_coordinates = (150, 150)

            for _ in range(6):
                location_filter.update_gps(
                    new_coordinates[0],
                    new_coordinates[1],
                    x_accuracy_m=0.1,
                    y_accuracy_m=0.1,
                    heading_d=heading_d,
                    speed_m_s=0
                )
            for estimated, expected in zip(
                location_filter.estimated_location(),
                new_coordinates
            ):
                self.assertAlmostEqual(estimated, expected, 2)
Example #2
0
def benchmark_location_filter_update_compass():
    """Benchmark the location filter compass update."""
    location_filter = LocationFilter(0.0, 0.0, 0.0)
    iterations = 100
    start = time.time()
    for _ in range(iterations):
        location_filter.update_compass(20.0)
    end = time.time()
    print('{} iterations of LocationFilter.update_compass, each took {:.5}'.
          format(iterations, (end - start) / float(iterations)))
Example #3
0
def benchmark_location_filter_update_dead_reckoning():
    """Benchmark the location filter with dead reckoning and no other input."""
    location_filter = LocationFilter(0.0, 0.0, 0.0)
    iterations = 1000
    start = time.time()
    for _ in range(iterations):
        location_filter.update_dead_reckoning()
    end = time.time()
    print(
        '{} iterations of LocationFilter.update_dead_reckoning, each took {:.5}'
        .format(iterations, (end - start) / float(iterations)))
Example #4
0
def benchmark_location_filter_update_dead_reckoning():
    """Benchmark the location filter with dead reckoning and no other input."""
    location_filter = LocationFilter(0.0, 0.0, 0.0)
    iterations = 1000
    start = time.time()
    for _ in range(iterations):
        location_filter.update_dead_reckoning()
    end = time.time()
    print(
        '{} iterations of LocationFilter.update_dead_reckoning, each took {:.5}'.format(
            iterations,
            (end - start) / float(iterations)
        )
    )
Example #5
0
def benchmark_location_filter_update_compass():
    """Benchmark the location filter compass update."""
    location_filter = LocationFilter(0.0, 0.0, 0.0)
    iterations = 100
    start = time.time()
    for _ in range(iterations):
        location_filter.update_compass(20.0)
    end = time.time()
    print(
        '{} iterations of LocationFilter.update_compass, each took {:.5}'.format(
            iterations,
            (end - start) / float(iterations)
        )
    )
    def __init__(self, kml_file_name=None):
        self._data = {}
        self._logger = AsyncLogger()
        self._speed_history = collections.deque()
        self._z_acceleration_g = collections.deque()
        self._lock = threading.Lock()

        # TODO: For the competition, just hard code the compass. For now, the
        # Kalman filter should start reading in values and correct quickly.
        self._location_filter = LocationFilter(0.0, 0.0, 0.0)
        self._estimated_steering = 0.0
        self._estimated_throttle = 0.0

        self._target_steering = 0.0
        self._target_throttle = 0.0

        self._ignored_points = collections.defaultdict(lambda: 0)
        self._ignored_points_thresholds = collections.defaultdict(lambda: 10)

        consume = lambda: consume_messages(config.TELEMETRY_EXCHANGE, self.
                                           _handle_message)
        thread = threading.Thread(target=consume)
        thread.name = '{}:consume_messages:{}'.format(
            self.__class__.__name__, config.TELEMETRY_EXCHANGE)
        thread.start()

        self._course_m = None
        try:
            if kml_file_name is not None:
                self.load_kml_from_file_name(kml_file_name)
                self._logger.info(
                    'Loaded {} course points and {} inner objects'.format(
                        len(self._course_m['course']),
                        len(self._course_m['inner'])))
            else:
                self._course_m = None

            if self._course_m is not None:
                if len(self._course_m['course']) == 0:
                    self._logger.warn(
                        'No course defined for {}'.format(kml_file_name))
                if len(self._course_m['inner']) == 0:
                    self._logger.warn(
                        'No inner obstacles defined for {}'.format(
                            kml_file_name))
        except Exception as e:
            self._logger.error('Unable to load course file: {}'.format(e))
Example #7
0
    def __init__(self, kml_file_name=None):
        self._data = {}
        self._logger = AsyncLogger()
        self._speed_history = collections.deque()
        self._z_acceleration_g = collections.deque()
        self._lock = threading.Lock()

        # TODO: For the competition, just hard code the compass. For now, the
        # Kalman filter should start reading in values and correct quickly.
        self._location_filter = LocationFilter(0.0, 0.0, 0.0)
        self._estimated_steering = 0.0
        self._estimated_throttle = 0.0

        self._target_steering = 0.0
        self._target_throttle = 0.0

        self._ignored_points = collections.defaultdict(lambda: 0)
        self._ignored_points_thresholds = collections.defaultdict(lambda: 10)

        consume = lambda: consume_messages(
            config.TELEMETRY_EXCHANGE,
            self._handle_message
        )
        thread = threading.Thread(target=consume)
        thread.name = '{}:consume_messages:{}'.format(
            self.__class__.__name__,
            config.TELEMETRY_EXCHANGE
        )
        thread.start()

        self._course_m = None
        try:
            if kml_file_name is not None:
                self.load_kml_from_file_name(kml_file_name)
                self._logger.info(
                    'Loaded {} course points and {} inner objects'.format(
                        len(self._course_m['course']),
                        len(self._course_m['inner'])
                    )
                )
            else:
                self._course_m = None

            if self._course_m is not None:
                if len(self._course_m['course']) == 0:
                    self._logger.warn(
                        'No course defined for {}'.format(kml_file_name)
                    )
                if len(self._course_m['inner']) == 0:
                    self._logger.warn(
                        'No inner obstacles defined for {}'.format(kml_file_name)
                    )
        except Exception as e:
            self._logger.error('Unable to load course file: {}'.format(e))
Example #8
0
    def test_estimate(self):
        """Tests that the estimating of the locations via both is sane."""
        start_coordinates_m = (100.0, 150.0)
        heading_d = 40.0
        location_filter = LocationFilter(start_coordinates_m[0],
                                         start_coordinates_m[1], heading_d)
        speed_m_s = 5.0
        # This would normally naturally get set by running the Kalman filter;
        # we'll just manually set it now
        location_filter._estimates[3].itemset(0, speed_m_s)

        self.assertEqual(location_filter.estimated_location(),
                         start_coordinates_m)

        tick_s = 0.5

        step_m = speed_m_s * tick_s
        step_x_m, step_y_m = Telemetry.rotate_radians_clockwise(
            (0.0, step_m), math.radians(heading_d))

        actual_x_m, actual_y_m = start_coordinates_m

        def check_estimates():
            self.assertLess(
                abs(location_filter.estimated_location()[0] - actual_x_m), 0.5)
            self.assertLess(
                abs(location_filter.estimated_location()[1] - actual_y_m), 0.5)

        for update in range(1, 21):
            actual_x_m += step_x_m
            actual_y_m += step_y_m

            # First update by compass
            measurements = numpy.matrix([0.0, 0.0, heading_d, 0.0]).transpose()
            location_filter._update(measurements,
                                    location_filter.COMPASS_OBSERVER_MATRIX,
                                    location_filter.COMPASS_MEASUREMENT_NOISE,
                                    tick_s)
            check_estimates()

            # Add some approximated GPS readings
            # We'll use very tight standard deviations to try to avoid random
            # test failures
            measurements = numpy.matrix([
                random.normalvariate(actual_x_m, 0.01),
                random.normalvariate(actual_y_m, 0.01),
                random.normalvariate(heading_d, 0.5),
                random.normalvariate(speed_m_s, speed_m_s * 0.1)
            ]).transpose()
            location_filter._update(
                measurements,
                location_filter.GPS_OBSERVER_MATRIX,
                location_filter.GPS_MEASUREMENT_NOISE,
                0.0  # Tick isn't used for GPS
            )
            check_estimates()
    def test_estimate_constant_speed(self):
        """Tests that the estimating of the locations via dead reckoning at a
        constant speed is sane.
        """
        # I'm not sure how to independently validate these tests for accuracy.
        # The best I can think of is to do some sanity tests.
        start_coordinates_m = (100.0, 200.0)
        heading_d = 32.0
        location_filter = LocationFilter(
            start_coordinates_m[0],
            start_coordinates_m[1],
            heading_d
        )

        self.assertEqual(
            location_filter.estimated_location(),
            start_coordinates_m
        )

        speed_m_s = 1.8
        # This would normally naturally get set by running the Kalman filter;
        # we'll just manually set it now
        location_filter._estimates[3].itemset(0, speed_m_s)

        self.assertEqual(
            location_filter.estimated_location(),
            start_coordinates_m
        )

        measurements = numpy.matrix(  # pylint: disable=no-member
            [0.0, 0.0, heading_d, 0.0]
        ).transpose()  # z

        seconds = 5
        for _ in range(seconds):
            location_filter._update(
                measurements,
                location_filter.COMPASS_OBSERVER_MATRIX,
                location_filter.COMPASS_MEASUREMENT_NOISE,
                time_diff_s=1.0
            )

        offset = Telemetry.rotate_radians_clockwise(
            (0.0, speed_m_s * seconds),
            math.radians(heading_d)
        )
        new_coordinates = [s + o for s, o in zip(start_coordinates_m, offset)]

        for estimated, expected in zip(
            location_filter.estimated_location(),
            new_coordinates
        ):
            self.assertAlmostEqual(estimated, expected, 2)
Example #10
0
    def test_estimate_constant_speed(self):
        """Tests that the estimating of the locations via dead reckoning at a
        constant speed is sane.
        """
        # I'm not sure how to independently validate these tests for accuracy.
        # The best I can think of is to do some sanity tests.
        start_coordinates_m = (100.0, 200.0)
        heading_d = 32.0
        location_filter = LocationFilter(start_coordinates_m[0],
                                         start_coordinates_m[1], heading_d)

        self.assertEqual(location_filter.estimated_location(),
                         start_coordinates_m)

        speed_m_s = 1.8
        # This would normally naturally get set by running the Kalman filter;
        # we'll just manually set it now
        location_filter._estimates[3].itemset(0, speed_m_s)

        self.assertEqual(location_filter.estimated_location(),
                         start_coordinates_m)

        measurements = numpy.matrix(  # pylint: disable=no-member
            [0.0, 0.0, heading_d, 0.0]).transpose()  # z

        seconds = 5
        for _ in range(seconds):
            location_filter._update(measurements,
                                    location_filter.COMPASS_OBSERVER_MATRIX,
                                    location_filter.COMPASS_MEASUREMENT_NOISE,
                                    time_diff_s=1.0)

        offset = Telemetry.rotate_radians_clockwise((0.0, speed_m_s * seconds),
                                                    math.radians(heading_d))
        new_coordinates = [s + o for s, o in zip(start_coordinates_m, offset)]

        for estimated, expected in zip(location_filter.estimated_location(),
                                       new_coordinates):
            self.assertAlmostEqual(estimated, expected, 2)
Example #11
0
    def test_estimate_gps(self):
        """Tests that the estimating of the locations via GPS is sane."""
        # I'm not sure how to independently validate these tests for accuracy.
        # The best I can think of is to do some sanity tests.
        heading_d = 0.0
        for coordinates in ((100, 200), (200, 100), (300, 300), (50, 50)):
            location_filter = LocationFilter(coordinates[0], coordinates[1],
                                             heading_d)

            self.assertEqual(location_filter.estimated_location(), coordinates)

            new_coordinates = (150, 150)

            for _ in range(6):
                location_filter.update_gps(new_coordinates[0],
                                           new_coordinates[1],
                                           x_accuracy_m=0.1,
                                           y_accuracy_m=0.1,
                                           heading_d=heading_d,
                                           speed_m_s=0)
            for estimated, expected in zip(
                    location_filter.estimated_location(), new_coordinates):
                self.assertAlmostEqual(estimated, expected, 2)
class Telemetry(object):
    """Provides up to date telemetry data to other modules. This class will use
    the current command direction, anomalous value filtering and interpolation
    to provide more accurate readings than just raw data dumps.
    """
    EQUATORIAL_RADIUS_M = 6378.1370 * 1000
    M_PER_D_LATITUDE = EQUATORIAL_RADIUS_M * 2.0 * math.pi / 360.0
    HISTORICAL_SPEED_READINGS_COUNT = 10
    HISTORICAL_ACCELEROMETER_READINGS_COUNT = 5

    def __init__(self, kml_file_name=None):
        self._data = {}
        self._logger = AsyncLogger()
        self._speed_history = collections.deque()
        self._z_acceleration_g = collections.deque()
        self._lock = threading.Lock()

        # TODO: For the competition, just hard code the compass. For now, the
        # Kalman filter should start reading in values and correct quickly.
        self._location_filter = LocationFilter(0.0, 0.0, 0.0)
        self._estimated_steering = 0.0
        self._estimated_throttle = 0.0

        self._target_steering = 0.0
        self._target_throttle = 0.0

        self._ignored_points = collections.defaultdict(lambda: 0)
        self._ignored_points_thresholds = collections.defaultdict(lambda: 10)

        consume = lambda: consume_messages(config.TELEMETRY_EXCHANGE, self.
                                           _handle_message)
        thread = threading.Thread(target=consume)
        thread.name = '{}:consume_messages:{}'.format(
            self.__class__.__name__, config.TELEMETRY_EXCHANGE)
        thread.start()

        self._course_m = None
        try:
            if kml_file_name is not None:
                self.load_kml_from_file_name(kml_file_name)
                self._logger.info(
                    'Loaded {} course points and {} inner objects'.format(
                        len(self._course_m['course']),
                        len(self._course_m['inner'])))
            else:
                self._course_m = None

            if self._course_m is not None:
                if len(self._course_m['course']) == 0:
                    self._logger.warn(
                        'No course defined for {}'.format(kml_file_name))
                if len(self._course_m['inner']) == 0:
                    self._logger.warn(
                        'No inner obstacles defined for {}'.format(
                            kml_file_name))
        except Exception as e:
            self._logger.error('Unable to load course file: {}'.format(e))

    @synchronized
    def get_raw_data(self):
        """Returns the raw most recent telemetry readings."""
        return self._data

    @synchronized
    def get_data(self, update=None):
        """Returns the approximated telemetry data."""
        if update is None:
            update = True
        if update:
            self._location_filter.update_dead_reckoning()
        values = {}
        for key in ('accelerometer_m_s_s',
                    ):  # Left as a loop if we want more later
            if key in self._data:
                values[key] = self._data[key]

        values['speed_m_s'] = self._location_filter.estimated_speed()
        values['heading_d'] = self._location_filter.estimated_heading()
        x_m, y_m = self._location_filter.estimated_location()
        values['x_m'], values['y_m'] = x_m, y_m
        latitude = self.offset_y_m_to_latitude(y_m)
        self._logger.debug('Estimates: {}'.format(
            json.dumps({
                'latitude_d':
                self.offset_y_m_to_latitude(y_m),
                'longitude_d':
                self.offset_x_m_to_longitude(x_m, latitude),
                'heading_d':
                values['heading_d'],
                'device_id':
                'estimate'
            })))
        values['throttle'] = self._estimated_throttle
        values['steering'] = self._estimated_steering
        return values

    @synchronized
    def process_drive_command(self, throttle, steering):
        """Process a drive command. When the command module tells the car to do
        something (e.g. drive forward and left), that data should be integrated
        into the telemetry immediately, because GPS sensors and what not
        normally have a slight delay.
        """
        assert -1.0 <= throttle <= 1.0, 'Bad throttle in telemetry'
        assert -1.0 <= steering <= 1.0, 'Bad steering in telemetry'

        self._target_steering = steering
        self._target_throttle = throttle

    def _handle_message(self, message):
        """Stores telemetry data from messages received from some source."""
        original_message = message
        message = json.loads(original_message)
        if 'speed_m_s' in message and message['speed_m_s'] <= MAX_SPEED_M_S:
            self._speed_history.append(message['speed_m_s'])
            while len(self._speed_history
                      ) > self.HISTORICAL_SPEED_READINGS_COUNT:
                self._speed_history.popleft()

        if 'compass_d' in message:
            self._update_estimated_drive()
            self._location_filter.update_compass(message['compass_d'],
                                                 message['confidence'])
            self._logger.debug(original_message)

        elif 'acceleration_g_z' in message:
            # TODO(skari): Detect if we've run into something
            self._z_acceleration_g.append(message['acceleration_g_z'])
            while len(self._z_acceleration_g
                      ) > self.HISTORICAL_ACCELEROMETER_READINGS_COUNT:
                self._z_acceleration_g.popleft()

            self._logger.debug(original_message)

        elif 'latitude_d' in message:
            if message['speed_m_s'] < MAX_SPEED_M_S:
                self._handle_gps_message(message)

            self._data = message
            self._logger.debug(original_message)

        elif 'load_waypoints' in message:
            self.load_kml_from_file_name(message['load_waypoints'])

        else:
            self._logger.debug(
                'Unexpected message: {}'.format(original_message))

    def _handle_gps_message(self, message):
        """Handles a GPS telemetry message."""
        device = message['device_id']
        point_m = (Telemetry.longitude_to_m_offset(message['longitude_d'],
                                                   message['latitude_d']),
                   Telemetry.latitude_to_m_offset(message['latitude_d']))
        if self._m_point_in_course(point_m):
            self._ignored_points[device] = 0
            self._ignored_points_thresholds[device] = 10
            message['x_m'] = point_m[0]
            message['y_m'] = point_m[1]

            self._update_estimated_drive()
            self._location_filter.update_gps(
                message['x_m'],
                message['y_m'],
                # The location filter supports accuracy in both directions,
                # but TelemetryProducer only reports one right now. I don't
                # think any of my sources report both right now.
                message['accuracy_m'],
                message['accuracy_m'],
                message['heading_d'],
                message['speed_m_s'])
        else:
            self._ignored_points[device] += 1
            if self._ignored_points[device] > self._ignored_points_thresholds[
                    device]:
                self._logger.info(
                    'Dropped {} out of bounds points from {} in a row'.format(
                        self._ignored_points[device], device))
                self._ignored_points[device] = 0
                self._ignored_points_thresholds[device] += 10
            else:
                self._logger.debug(
                    'Ignoring out of bounds point: {}'.format(point_m))

            # In general, I've found that speed and heading readings tend
            # to be fairly accurate, even if the actual coordinates are
            # off. i.e., GPS readings are usually consistently off by N
            # meters in the short term and not random all over the place.
            if 'heading_d' in message and 'speed_m_s' in message:
                heading_d = message['heading_d']
                speed_m_s = message['speed_m_s']
                # iPhone sometimes produces null if there is no speed fix yet
                if heading_d is not None and speed_m_s is not None:
                    if speed_m_s <= MAX_SPEED_M_S:
                        self._location_filter.update_heading_and_speed(
                            heading_d, speed_m_s)
                    else:
                        self._logger.debug(
                            'Ignoring too high of speed value: {}'.format(
                                speed_m_s))

    @synchronized
    def is_stopped(self):
        """Determines if the RC car is moving."""
        if len(self._speed_history) < self.HISTORICAL_SPEED_READINGS_COUNT:
            return False

        if all((speed == 0.0 for speed in self._speed_history)):
            self._speed_history.clear()
            return True
        return False

    @synchronized
    def is_inverted(self):
        """Determines if the RC car is inverted."""
        if len(self._z_acceleration_g
               ) < self.HISTORICAL_ACCELEROMETER_READINGS_COUNT:
            return False

        if all((z_acceleration_g < 0.0
                for z_acceleration_g in self._z_acceleration_g)):
            self._z_acceleration_g.clear()
            return True
        return False

    @synchronized
    def load_kml_from_file_name(self, kml_file_name):
        """Loads KML from a file name."""
        directory = 'paths' + os.sep
        if not kml_file_name.startswith(directory):
            kml_file_name = directory + kml_file_name
        if kml_file_name.endswith('.kmz'):
            import zipfile
            with zipfile.ZipFile(kml_file_name) as archive:
                self._course_m = self._load_kml_from_stream(
                    archive.open('doc.kml'))
        else:
            with open(kml_file_name) as stream:
                self._course_m = self._load_kml_from_stream(stream)

    def _update_estimated_drive(self):
        """Updates the estimations of the drive state, e.g. the current
        throttle and steering.
        """
        self._estimated_throttle = self._target_throttle
        self._estimated_steering = self._target_steering

        # Also tell the location filter that we've changed
        if self._estimated_throttle != self._target_throttle:
            self._location_filter.manual_throttle(self._estimated_throttle *
                                                  MAX_SPEED_M_S)
        # Values for Tamiya Grasshopper, from observation. This is at .5
        # throttle, but we turn faster at higher speeds.
        BASE_MAX_TURN_RATE_D_S = 150.0
        # We always update the steering change, because we don't have sensors
        # to get estimates for it from other sources for our Kalman filter
        if self._estimated_throttle > 0:
            self._location_filter.manual_steering(self._estimated_steering *
                                                  BASE_MAX_TURN_RATE_D_S)
        elif self._estimated_throttle < 0:
            self._location_filter.manual_steering(self._estimated_steering *
                                                  BASE_MAX_TURN_RATE_D_S)
        else:
            self._location_filter.manual_steering(0)

    def _load_kml_from_stream(self, kml_stream):
        """Loads the course boundaries from a KML file."""
        course = collections.defaultdict(lambda: [])

        def get_child(element, tag_name):
            """Returns the child element with the given tag name."""
            try:
                return getattr(element, tag_name)
            except AttributeError:
                raise ValueError('No {tag} element found'.format(tag=tag_name))

        root = parser.parse(kml_stream).getroot()
        if 'kml' not in root.tag:
            self._logger.warn('Not a KML file')
            return None

        document = get_child(root, 'Document')
        for placemark in document.iterchildren():
            if not placemark.tag.endswith('Placemark'):
                continue

            try:
                polygon = get_child(placemark, 'Polygon')
            except ValueError:
                # The KML also includes Path elements; those are fine
                continue

            bound = get_child(polygon, 'outerBoundaryIs')
            ring = get_child(bound, 'LinearRing')
            coordinates = get_child(ring, 'coordinates')
            waypoints = []
            text = coordinates.text.strip()
            for csv in re.split(r'\s', text):
                (
                    longitude,
                    latitude,
                    altitude  # pylint: disable=unused-variable
                ) = csv.split(',')

                waypoints.append((
                    Telemetry.longitude_to_m_offset(float(longitude),
                                                    float(latitude)),
                    Telemetry.latitude_to_m_offset(float(latitude)),
                ))

            if str(placemark.name).startswith('course'):
                course['course'] = waypoints
            elif str(placemark.name).startswith('inner'):
                course['inner'].append(waypoints)

        return course

    def _m_point_in_course(self, point_m):
        if self._course_m is None:
            return True
        if not self.point_in_polygon(point_m, self._course_m['course']):
            return False
        for inner in self._course_m['inner']:
            if self.point_in_polygon(point_m, inner):
                return False
        return True

    @staticmethod
    def rotate_radians_clockwise(point, radians):
        """Rotates the point by radians."""
        pt_x, pt_y = point
        cosine = math.cos(-radians)
        sine = math.sin(-radians)
        return (pt_x * cosine - pt_y * sine, pt_x * sine + pt_y * cosine)

    @staticmethod
    def rotate_degrees_clockwise(point, degrees):
        """Rotates the point by degrees."""
        pt_x, pt_y = point
        cosine = math.cos(math.radians(-degrees))
        sine = math.sin(math.radians(-degrees))
        return (pt_x * cosine - pt_y * sine, pt_x * sine + pt_y * cosine)

    @classmethod
    def m_per_d_latitude(cls):
        """Returns the numbers of meters per degree of latitude."""
        return cls.M_PER_D_LATITUDE

    @classmethod
    def latitude_to_m_per_d_longitude(cls, latitude_d, cache=None):
        """Returns the number of meters per degree longitude at a given
        latitude.
        """
        def calculate(latitude_d):
            """Calculates the number of meters per degree longitude at a
            given latitude.
            """
            radius_m = \
                math.cos(math.radians(latitude_d)) * cls.EQUATORIAL_RADIUS_M
            circumference_m = 2.0 * math.pi * radius_m
            return circumference_m / 360.0

        if cache is not None and cache:
            return calculate(latitude_d)

        if 'cache' not in Telemetry.latitude_to_m_per_d_longitude.__dict__:
            Telemetry.latitude_to_m_per_d_longitude.__dict__['cache'] = [
                latitude_d, calculate(latitude_d)
            ]

        cache = Telemetry.latitude_to_m_per_d_longitude.cache
        if cache is not None and latitude_d - 0.1 < cache[0] < latitude_d + 0.1:
            return cache[1]
        cache[0] = latitude_d
        cache[1] = calculate(latitude_d)
        return cache[1]

    @classmethod
    def distance_m(cls, latitude_d_1, longitude_d_1, latitude_d_2,
                   longitude_d_2):
        """Returns the distance in meters between two waypoints in degrees."""
        diff_latitude_d = latitude_d_1 - latitude_d_2
        diff_longitude_d = longitude_d_1 - longitude_d_2
        diff_1_m = diff_latitude_d * cls.m_per_d_latitude()
        diff_2_m = (diff_longitude_d *
                    Telemetry.latitude_to_m_per_d_longitude(latitude_d_1))
        return math.sqrt(diff_1_m**2.0 + diff_2_m**2.0)

    @staticmethod
    def is_turn_left(heading_d, goal_heading_d):
        """Determines if the vehicle facing a heading in degrees needs to turn
        left to reach a goal heading in degrees.
        """
        pt_1 = Telemetry.rotate_degrees_clockwise((1, 0), heading_d)
        pt_2 = Telemetry.rotate_degrees_clockwise((1, 0), goal_heading_d)
        pt_1 = list(pt_1) + [0]
        pt_2 = list(pt_2) + [0]
        cross_product = \
            pt_1[1] * pt_2[2] - pt_1[2] * pt_2[1] \
            + pt_1[2] * pt_2[0] - pt_1[0] * pt_2[2] \
            + pt_1[0] * pt_2[1] - pt_1[1] * pt_2[0]
        if cross_product > 0:
            return True
        return False

    @staticmethod
    def relative_degrees(x_m_1, y_m_1, x_m_2, y_m_2):
        """Computes the relative degrees from the first waypoint to the second,
        where north is 0.
        """
        relative_y_m = float(y_m_2) - y_m_1
        relative_x_m = float(x_m_2) - x_m_1
        if relative_x_m == 0.0:
            if relative_y_m > 0.0:
                return 0.0
            return 180.0

        degrees = math.degrees(math.atan(relative_y_m / relative_x_m))
        if relative_x_m > 0.0:
            return 90.0 - degrees
        else:
            return 270.0 - degrees

    @staticmethod
    def acceleration_mss_velocity_ms_to_radius_m(acceleration_m_s_s,
                                                 velocity_m_s):
        """Converts the lateral acceleration force (accessible from the Android
        phone) and the car's velocity to the car's turn radius in meters.
        """
        # centripetal acceleration = velocity ^ 2 / radius
        return velocity_m_s**2 / acceleration_m_s_s

    @staticmethod
    def acceleration_mss_velocity_ms_to_ds(acceleration_m_s_s, velocity_m_s):
        """Converts the lateral acceleration force (accessible from the Android
        phone) and the car's velocity to the car's turn rate in degrees per
        second.
        """
        radius_m = Telemetry.acceleration_mss_velocity_ms_to_radius_m(
            acceleration_m_s_s, velocity_m_s)
        circumference_m = 2 * math.pi * radius_m
        return circumference_m / float(velocity_m_s) * 360.0

    @staticmethod
    def wrap_degrees(degrees):
        """Wraps a degree value that's too high or too low."""
        dividend = int(degrees) // 360
        return (degrees + (dividend + 1) * 360.0) % 360.0

    @staticmethod
    def difference_d(heading_1_d, heading_2_d):
        """Calculates the absolute difference in degrees between two
        headings.
        """
        wrap_1_d = Telemetry.wrap_degrees(heading_1_d)
        wrap_2_d = Telemetry.wrap_degrees(heading_2_d)
        diff_d = abs(wrap_1_d - wrap_2_d)
        if diff_d > 180.0:
            diff_d = 360.0 - diff_d
        return diff_d

    @classmethod
    def latitude_to_m_offset(cls, latitude_d):
        """Returns the offset in meters for a given coordinate."""
        y_m = cls.m_per_d_latitude() * (latitude_d - CENTRAL_LATITUDE)
        return y_m

    @classmethod
    def longitude_to_m_offset(cls, longitude_d, latitude_d):
        """Returns the offset in meters for a given coordinate."""
        x_m = cls.latitude_to_m_per_d_longitude(latitude_d) * (
            longitude_d - CENTRAL_LONGITUDE)
        return x_m

    @classmethod
    def offset_y_m_to_latitude(cls, y_m):
        """Returns the inverse of latitude_to_m_offset."""
        return y_m / cls.m_per_d_latitude() + CENTRAL_LATITUDE

    @classmethod
    def offset_x_m_to_longitude(cls, x_m, latitude_d):
        """Returns the inverse of longitude_to_m_offset."""
        distance = cls.latitude_to_m_per_d_longitude(latitude_d)
        return x_m / distance + CENTRAL_LONGITUDE

    @staticmethod
    def distance_to_waypoint(heading_d_1, heading_d_2, distance_travelled):
        """Calculates the distance to a waypoint, given two observed headings
        to the waypoint and distance travelled in a straight line between the
        two observations.
        """
        m_1 = math.tan(math.radians(90.0 - heading_d_1))
        m_2 = math.tan(math.radians(90.0 - heading_d_2))
        x = distance_travelled / (m_1 - m_2)
        hypotenuse = x / math.cos(math.radians(90.0 - heading_d_1))
        return hypotenuse

    @staticmethod
    def offset_from_waypoint(heading_d, offset_to_waypoint_d, distance):
        """Calculates the offset (x, y) from a waypoint, given the heading of
        the vehicle, the angle from the vehicle's heading to the waypoint, and
        the distance to the waypoint.
        """
        angle = Telemetry.wrap_degrees(180.0 + heading_d +
                                       offset_to_waypoint_d)
        return Telemetry.rotate_degrees_clockwise((0.0, distance), angle)

    @staticmethod
    def intersects(a, b, c, d):
        """Returns True if two line segments intersect."""
        def ccw(a, b, c):
            return (c[1] - a[1]) * (b[0] - a[0]) > (b[1] - a[1]) * (c[0] -
                                                                    a[0])

        return ccw(a, c, d) != ccw(b, c, d) and ccw(a, b, c) != ccw(a, b, d)

    @staticmethod
    def point_in_polygon(point, polygon):
        """Returns true if a point is strictly inside of a simple polygon."""
        min_x = min(p[0] for p in polygon)
        min_y = min(p[1] for p in polygon)
        # To avoid degenerate parallel cases, put some arbitrary numbers here
        outside = (min_x - .029238029833, min_y - .0132323872)

        inside = False

        next_point = iter(polygon)
        next(next_point)
        for p1, p2 in zip(polygon, next_point):
            if Telemetry.intersects(outside, point, p1, p2):
                inside = not inside
        if Telemetry.intersects(outside, point, polygon[-1], polygon[0]):
            inside = not inside

        return inside
    def test_estimate(self):
        """Tests that the estimating of the locations via both is sane."""
        start_coordinates_m = (100.0, 150.0)
        heading_d = 40.0
        location_filter = LocationFilter(
            start_coordinates_m[0],
            start_coordinates_m[1],
            heading_d
        )
        speed_m_s = 5.0
        # This would normally naturally get set by running the Kalman filter;
        # we'll just manually set it now
        location_filter._estimates[3].itemset(0, speed_m_s)

        self.assertEqual(
            location_filter.estimated_location(),
            start_coordinates_m
        )

        tick_s = 0.5

        step_m = speed_m_s * tick_s
        step_x_m, step_y_m = Telemetry.rotate_radians_clockwise(
            (0.0, step_m),
            math.radians(heading_d)
        )

        actual_x_m, actual_y_m = start_coordinates_m

        def check_estimates():
            self.assertLess(
                abs(location_filter.estimated_location()[0] - actual_x_m),
                0.5
            )
            self.assertLess(
                abs(location_filter.estimated_location()[1] - actual_y_m),
                0.5
            )

        for update in range(1, 21):
            actual_x_m += step_x_m
            actual_y_m += step_y_m

            # First update by compass
            measurements = numpy.matrix([0.0, 0.0, heading_d, 0.0]).transpose()
            location_filter._update(
                measurements,
                location_filter.COMPASS_OBSERVER_MATRIX,
                location_filter.COMPASS_MEASUREMENT_NOISE,
                tick_s
            )
            check_estimates()

            # Add some approximated GPS readings
            # We'll use very tight standard deviations to try to avoid random
            # test failures
            measurements = numpy.matrix([
                random.normalvariate(actual_x_m, 0.01),
                random.normalvariate(actual_y_m, 0.01),
                random.normalvariate(heading_d, 0.5),
                random.normalvariate(speed_m_s, speed_m_s * 0.1)
            ]).transpose()
            location_filter._update(
                measurements,
                location_filter.GPS_OBSERVER_MATRIX,
                location_filter.GPS_MEASUREMENT_NOISE,
                0.0  # Tick isn't used for GPS
            )
            check_estimates()
Example #14
0
class Telemetry(object):
    """Provides up to date telemetry data to other modules. This class will use
    the current command direction, anomalous value filtering and interpolation
    to provide more accurate readings than just raw data dumps.
    """
    EQUATORIAL_RADIUS_M = 6378.1370 * 1000
    M_PER_D_LATITUDE = EQUATORIAL_RADIUS_M * 2.0 * math.pi / 360.0
    HISTORICAL_SPEED_READINGS_COUNT = 10
    HISTORICAL_ACCELEROMETER_READINGS_COUNT = 5

    def __init__(self, kml_file_name=None):
        self._data = {}
        self._logger = AsyncLogger()
        self._speed_history = collections.deque()
        self._z_acceleration_g = collections.deque()
        self._lock = threading.Lock()

        # TODO: For the competition, just hard code the compass. For now, the
        # Kalman filter should start reading in values and correct quickly.
        self._location_filter = LocationFilter(0.0, 0.0, 0.0)
        self._estimated_steering = 0.0
        self._estimated_throttle = 0.0

        self._target_steering = 0.0
        self._target_throttle = 0.0

        self._ignored_points = collections.defaultdict(lambda: 0)
        self._ignored_points_thresholds = collections.defaultdict(lambda: 10)

        consume = lambda: consume_messages(
            config.TELEMETRY_EXCHANGE,
            self._handle_message
        )
        thread = threading.Thread(target=consume)
        thread.name = '{}:consume_messages:{}'.format(
            self.__class__.__name__,
            config.TELEMETRY_EXCHANGE
        )
        thread.start()

        self._course_m = None
        try:
            if kml_file_name is not None:
                self.load_kml_from_file_name(kml_file_name)
                self._logger.info(
                    'Loaded {} course points and {} inner objects'.format(
                        len(self._course_m['course']),
                        len(self._course_m['inner'])
                    )
                )
            else:
                self._course_m = None

            if self._course_m is not None:
                if len(self._course_m['course']) == 0:
                    self._logger.warn(
                        'No course defined for {}'.format(kml_file_name)
                    )
                if len(self._course_m['inner']) == 0:
                    self._logger.warn(
                        'No inner obstacles defined for {}'.format(kml_file_name)
                    )
        except Exception as e:
            self._logger.error('Unable to load course file: {}'.format(e))

    @synchronized
    def get_raw_data(self):
        """Returns the raw most recent telemetry readings."""
        return self._data

    @synchronized
    def get_data(self, update=None):
        """Returns the approximated telemetry data."""
        if update is None:
            update = True
        if update:
            self._location_filter.update_dead_reckoning()
        values = {}
        for key in ('accelerometer_m_s_s',):  # Left as a loop if we want more later
            if key in self._data:
                values[key] = self._data[key]

        values['speed_m_s'] = self._location_filter.estimated_speed()
        values['heading_d'] = self._location_filter.estimated_heading()
        x_m, y_m = self._location_filter.estimated_location()
        values['x_m'], values['y_m'] = x_m, y_m
        latitude = self.offset_y_m_to_latitude(y_m)
        self._logger.debug(
            'Estimates: {}'.format(
                json.dumps({
                    'latitude_d': self.offset_y_m_to_latitude(y_m),
                    'longitude_d': self.offset_x_m_to_longitude(x_m, latitude),
                    'heading_d': values['heading_d'],
                    'device_id': 'estimate'
                })
            )
        )
        values['throttle'] = self._estimated_throttle
        values['steering'] = self._estimated_steering
        return values

    @synchronized
    def process_drive_command(self, throttle, steering):
        """Process a drive command. When the command module tells the car to do
        something (e.g. drive forward and left), that data should be integrated
        into the telemetry immediately, because GPS sensors and what not
        normally have a slight delay.
        """
        assert -1.0 <= throttle <= 1.0, 'Bad throttle in telemetry'
        assert -1.0 <= steering <= 1.0, 'Bad steering in telemetry'

        self._target_steering = steering
        self._target_throttle = throttle

    def _handle_message(self, message):
        """Stores telemetry data from messages received from some source."""
        original_message = message
        message = json.loads(original_message)
        if 'speed_m_s' in message and message['speed_m_s'] <= MAX_SPEED_M_S:
            self._speed_history.append(message['speed_m_s'])
            while len(self._speed_history) > self.HISTORICAL_SPEED_READINGS_COUNT:
                self._speed_history.popleft()

        if 'compass_d' in message:
            self._update_estimated_drive()
            self._location_filter.update_compass(
                message['compass_d'],
                message['confidence']
            )
            self._logger.debug(original_message)

        elif 'acceleration_g_z' in message:
            # TODO(skari): Detect if we've run into something
            self._z_acceleration_g.append(message['acceleration_g_z'])
            while len(self._z_acceleration_g) > self.HISTORICAL_ACCELEROMETER_READINGS_COUNT:
                self._z_acceleration_g.popleft()

            self._logger.debug(original_message)

        elif 'latitude_d' in message:
            if message['speed_m_s'] < MAX_SPEED_M_S:
                self._handle_gps_message(message)

            self._data = message
            self._logger.debug(original_message)

        elif 'load_waypoints' in message:
            self.load_kml_from_file_name(message['load_waypoints'])

        else:
            self._logger.debug(
                'Unexpected message: {}'.format(original_message)
            )

    def _handle_gps_message(self, message):
        """Handles a GPS telemetry message."""
        device = message['device_id']
        point_m = (
            Telemetry.longitude_to_m_offset(
                message['longitude_d'],
                message['latitude_d']
            ),
            Telemetry.latitude_to_m_offset(message['latitude_d'])
        )
        if self._m_point_in_course(point_m):
            self._ignored_points[device] = 0
            self._ignored_points_thresholds[device] = 10
            message['x_m'] = point_m[0]
            message['y_m'] = point_m[1]

            self._update_estimated_drive()
            self._location_filter.update_gps(
                message['x_m'],
                message['y_m'],
                # The location filter supports accuracy in both directions,
                # but TelemetryProducer only reports one right now. I don't
                # think any of my sources report both right now.
                message['accuracy_m'],
                message['accuracy_m'],
                message['heading_d'],
                message['speed_m_s']
            )
        else:
            self._ignored_points[device] += 1
            if self._ignored_points[device] > self._ignored_points_thresholds[device]:
                self._logger.info(
                    'Dropped {} out of bounds points from {} in a row'.format(
                        self._ignored_points[device],
                        device
                    )
                )
                self._ignored_points[device] = 0
                self._ignored_points_thresholds[device] += 10
            else:
                self._logger.debug(
                    'Ignoring out of bounds point: {}'.format(point_m)
                )

            # In general, I've found that speed and heading readings tend
            # to be fairly accurate, even if the actual coordinates are
            # off. i.e., GPS readings are usually consistently off by N
            # meters in the short term and not random all over the place.
            if 'heading_d' in message and 'speed_m_s' in message:
                heading_d = message['heading_d']
                speed_m_s = message['speed_m_s']
                # iPhone sometimes produces null if there is no speed fix yet
                if heading_d is not None and speed_m_s is not None:
                    if speed_m_s <= MAX_SPEED_M_S:
                        self._location_filter.update_heading_and_speed(
                            heading_d,
                            speed_m_s
                        )
                    else:
                        self._logger.debug(
                            'Ignoring too high of speed value: {}'.format(
                                speed_m_s
                            )
                        )

    @synchronized
    def is_stopped(self):
        """Determines if the RC car is moving."""
        if len(self._speed_history) < self.HISTORICAL_SPEED_READINGS_COUNT:
            return False

        if all((speed == 0.0 for speed in self._speed_history)):
            self._speed_history.clear()
            return True
        return False

    @synchronized
    def is_inverted(self):
        """Determines if the RC car is inverted."""
        if len(self._z_acceleration_g) < self.HISTORICAL_ACCELEROMETER_READINGS_COUNT:
            return False

        if all((z_acceleration_g < 0.0 for z_acceleration_g in self._z_acceleration_g)):
            self._z_acceleration_g.clear()
            return True
        return False

    @synchronized
    def load_kml_from_file_name(self, kml_file_name):
        """Loads KML from a file name."""
        directory = 'paths' + os.sep
        if not kml_file_name.startswith(directory):
            kml_file_name = directory + kml_file_name
        if kml_file_name.endswith('.kmz'):
            import zipfile
            with zipfile.ZipFile(kml_file_name) as archive:
                self._course_m = self._load_kml_from_stream(archive.open('doc.kml'))
        else:
            with open(kml_file_name) as stream:
                self._course_m = self._load_kml_from_stream(stream)

    def _update_estimated_drive(self):
        """Updates the estimations of the drive state, e.g. the current
        throttle and steering.
        """
        self._estimated_throttle = self._target_throttle
        self._estimated_steering = self._target_steering

        # Also tell the location filter that we've changed
        if self._estimated_throttle != self._target_throttle:
            self._location_filter.manual_throttle(
                self._estimated_throttle * MAX_SPEED_M_S
            )
        # Values for Tamiya Grasshopper, from observation. This is at .5
        # throttle, but we turn faster at higher speeds.
        BASE_MAX_TURN_RATE_D_S = 150.0
        # We always update the steering change, because we don't have sensors
        # to get estimates for it from other sources for our Kalman filter
        if self._estimated_throttle > 0:
            self._location_filter.manual_steering(
                self._estimated_steering * BASE_MAX_TURN_RATE_D_S
            )
        elif self._estimated_throttle < 0:
            self._location_filter.manual_steering(
                self._estimated_steering * BASE_MAX_TURN_RATE_D_S
            )
        else:
            self._location_filter.manual_steering(0)

    def _load_kml_from_stream(self, kml_stream):
        """Loads the course boundaries from a KML file."""
        course = collections.defaultdict(lambda: [])

        def get_child(element, tag_name):
            """Returns the child element with the given tag name."""
            try:
                return getattr(element, tag_name)
            except AttributeError:
                raise ValueError('No {tag} element found'.format(tag=tag_name))

        root = parser.parse(kml_stream).getroot()
        if 'kml' not in root.tag:
            self._logger.warn('Not a KML file')
            return None

        document = get_child(root, 'Document')
        for placemark in document.iterchildren():
            if not placemark.tag.endswith('Placemark'):
                continue

            try:
                polygon = get_child(placemark, 'Polygon')
            except ValueError:
                # The KML also includes Path elements; those are fine
                continue

            bound = get_child(polygon, 'outerBoundaryIs')
            ring = get_child(bound, 'LinearRing')
            coordinates = get_child(ring, 'coordinates')
            waypoints = []
            text = coordinates.text.strip()
            for csv in re.split(r'\s', text):
                (
                    longitude,
                    latitude,
                    altitude  # pylint: disable=unused-variable
                ) = csv.split(',')

                waypoints.append((
                    Telemetry.longitude_to_m_offset(float(longitude), float(latitude)),
                    Telemetry.latitude_to_m_offset(float(latitude)),
                ))

            if str(placemark.name).startswith('course'):
                course['course'] = waypoints
            elif str(placemark.name).startswith('inner'):
                course['inner'].append(waypoints)

        return course

    def _m_point_in_course(self, point_m):
        if self._course_m is None:
            return True
        if not self.point_in_polygon(point_m, self._course_m['course']):
            return False
        for inner in self._course_m['inner']:
            if self.point_in_polygon(point_m, inner):
                return False
        return True

    @staticmethod
    def rotate_radians_clockwise(point, radians):
        """Rotates the point by radians."""
        pt_x, pt_y = point
        cosine = math.cos(-radians)
        sine = math.sin(-radians)
        return (
            pt_x * cosine - pt_y * sine,
            pt_x * sine + pt_y * cosine
        )

    @staticmethod
    def rotate_degrees_clockwise(point, degrees):
        """Rotates the point by degrees."""
        pt_x, pt_y = point
        cosine = math.cos(math.radians(-degrees))
        sine = math.sin(math.radians(-degrees))
        return (
            pt_x * cosine - pt_y * sine,
            pt_x * sine + pt_y * cosine
        )

    @classmethod
    def m_per_d_latitude(cls):
        """Returns the numbers of meters per degree of latitude."""
        return cls.M_PER_D_LATITUDE

    @classmethod
    def latitude_to_m_per_d_longitude(cls, latitude_d, cache=None):
        """Returns the number of meters per degree longitude at a given
        latitude.
        """
        def calculate(latitude_d):
            """Calculates the number of meters per degree longitude at a
            given latitude.
            """
            radius_m = \
                math.cos(math.radians(latitude_d)) * cls.EQUATORIAL_RADIUS_M
            circumference_m = 2.0 * math.pi * radius_m
            return circumference_m / 360.0

        if cache is not None and cache:
            return calculate(latitude_d)

        if 'cache' not in Telemetry.latitude_to_m_per_d_longitude.__dict__:
            Telemetry.latitude_to_m_per_d_longitude.__dict__['cache'] = [
                latitude_d,
                calculate(latitude_d)
            ]

        cache = Telemetry.latitude_to_m_per_d_longitude.cache
        if cache is not None and latitude_d - 0.1 < cache[0] < latitude_d + 0.1:
            return cache[1]
        cache[0] = latitude_d
        cache[1] = calculate(latitude_d)
        return cache[1]

    @classmethod
    def distance_m(
            cls,
            latitude_d_1,
            longitude_d_1,
            latitude_d_2,
            longitude_d_2
    ):
        """Returns the distance in meters between two waypoints in degrees."""
        diff_latitude_d = latitude_d_1 - latitude_d_2
        diff_longitude_d = longitude_d_1 - longitude_d_2
        diff_1_m = diff_latitude_d * cls.m_per_d_latitude()
        diff_2_m = (
            diff_longitude_d
            * Telemetry.latitude_to_m_per_d_longitude(latitude_d_1)
        )
        return math.sqrt(diff_1_m ** 2.0 + diff_2_m ** 2.0)

    @staticmethod
    def is_turn_left(heading_d, goal_heading_d):
        """Determines if the vehicle facing a heading in degrees needs to turn
        left to reach a goal heading in degrees.
        """
        pt_1 = Telemetry.rotate_degrees_clockwise(
            (1, 0),
            heading_d
        )
        pt_2 = Telemetry.rotate_degrees_clockwise(
            (1, 0),
            goal_heading_d
        )
        pt_1 = list(pt_1) + [0]
        pt_2 = list(pt_2) + [0]
        cross_product = \
            pt_1[1] * pt_2[2] - pt_1[2] * pt_2[1] \
            + pt_1[2] * pt_2[0] - pt_1[0] * pt_2[2] \
            + pt_1[0] * pt_2[1] - pt_1[1] * pt_2[0]
        if cross_product > 0:
            return True
        return False

    @staticmethod
    def relative_degrees(x_m_1, y_m_1, x_m_2, y_m_2):
        """Computes the relative degrees from the first waypoint to the second,
        where north is 0.
        """
        relative_y_m = float(y_m_2) - y_m_1
        relative_x_m = float(x_m_2) - x_m_1
        if relative_x_m == 0.0:
            if relative_y_m > 0.0:
                return 0.0
            return 180.0

        degrees = math.degrees(math.atan(relative_y_m / relative_x_m))
        if relative_x_m > 0.0:
            return 90.0 - degrees
        else:
            return 270.0 - degrees

    @staticmethod
    def acceleration_mss_velocity_ms_to_radius_m(
            acceleration_m_s_s,
            velocity_m_s
    ):
        """Converts the lateral acceleration force (accessible from the Android
        phone) and the car's velocity to the car's turn radius in meters.
        """
        # centripetal acceleration = velocity ^ 2 / radius
        return velocity_m_s ** 2 / acceleration_m_s_s

    @staticmethod
    def acceleration_mss_velocity_ms_to_ds(
            acceleration_m_s_s,
            velocity_m_s
    ):
        """Converts the lateral acceleration force (accessible from the Android
        phone) and the car's velocity to the car's turn rate in degrees per
        second.
        """
        radius_m = Telemetry.acceleration_mss_velocity_ms_to_radius_m(
            acceleration_m_s_s,
            velocity_m_s
        )
        circumference_m = 2 * math.pi * radius_m
        return circumference_m / float(velocity_m_s) * 360.0

    @staticmethod
    def wrap_degrees(degrees):
        """Wraps a degree value that's too high or too low."""
        dividend = int(degrees) // 360
        return (degrees + (dividend + 1) * 360.0) % 360.0

    @staticmethod
    def difference_d(heading_1_d, heading_2_d):
        """Calculates the absolute difference in degrees between two
        headings.
        """
        wrap_1_d = Telemetry.wrap_degrees(heading_1_d)
        wrap_2_d = Telemetry.wrap_degrees(heading_2_d)
        diff_d = abs(wrap_1_d - wrap_2_d)
        if diff_d > 180.0:
            diff_d = 360.0 - diff_d
        return diff_d

    @classmethod
    def latitude_to_m_offset(cls, latitude_d):
        """Returns the offset in meters for a given coordinate."""
        y_m = cls.m_per_d_latitude() * (latitude_d - CENTRAL_LATITUDE)
        return y_m

    @classmethod
    def longitude_to_m_offset(cls, longitude_d, latitude_d):
        """Returns the offset in meters for a given coordinate."""
        x_m = cls.latitude_to_m_per_d_longitude(latitude_d) * (longitude_d - CENTRAL_LONGITUDE)
        return x_m

    @classmethod
    def offset_y_m_to_latitude(cls, y_m):
        """Returns the inverse of latitude_to_m_offset."""
        return y_m / cls.m_per_d_latitude() + CENTRAL_LATITUDE

    @classmethod
    def offset_x_m_to_longitude(cls, x_m, latitude_d):
        """Returns the inverse of longitude_to_m_offset."""
        distance = cls.latitude_to_m_per_d_longitude(latitude_d)
        return x_m / distance + CENTRAL_LONGITUDE

    @staticmethod
    def distance_to_waypoint(heading_d_1, heading_d_2, distance_travelled):
        """Calculates the distance to a waypoint, given two observed headings
        to the waypoint and distance travelled in a straight line between the
        two observations.
        """
        m_1 = math.tan(math.radians(90.0 - heading_d_1))
        m_2 = math.tan(math.radians(90.0 - heading_d_2))
        x = distance_travelled / (m_1 - m_2)
        hypotenuse = x / math.cos(math.radians(90.0 - heading_d_1))
        return hypotenuse

    @staticmethod
    def offset_from_waypoint(heading_d, offset_to_waypoint_d, distance):
        """Calculates the offset (x, y) from a waypoint, given the heading of
        the vehicle, the angle from the vehicle's heading to the waypoint, and
        the distance to the waypoint.
        """
        angle = Telemetry.wrap_degrees(180.0 + heading_d + offset_to_waypoint_d)
        return Telemetry.rotate_degrees_clockwise(
            (0.0, distance),
            angle
        )

    @staticmethod
    def intersects(a, b, c, d):
        """Returns True if two line segments intersect."""
        def ccw(a, b, c):
            return (c[1] - a[1]) * (b[0] - a[0]) > (b[1] - a[1]) * (c[0] - a[0])

        return ccw(a, c, d) != ccw(b, c, d) and ccw(a, b, c) != ccw(a, b, d)

    @staticmethod
    def point_in_polygon(point, polygon):
        """Returns true if a point is strictly inside of a simple polygon."""
        min_x = min(p[0] for p in polygon)
        min_y = min(p[1] for p in polygon)
        # To avoid degenerate parallel cases, put some arbitrary numbers here
        outside = (min_x - .029238029833, min_y - .0132323872)

        inside = False

        next_point = iter(polygon)
        next(next_point)
        for p1, p2 in zip(polygon, next_point):
            if Telemetry.intersects(outside, point, p1, p2):
                inside = not inside
        if Telemetry.intersects(outside, point, polygon[-1], polygon[0]):
            inside = not inside

        return inside