Exemple #1
0
class DummyDriver(object):
    """Dummy class that implements the Driver interface."""

    def __init__(self, telemetry):
        self._telemetry = telemetry
        self._logger = AsyncLogger()
        self._throttle = 0.0
        self._steering= 0.0

    def drive(self, throttle_percentage, steering_percentage):
        """Sends a command to the RC car. Throttle should be a float between
        -1.0 for reverse and 1.0 for forward. Steering should be a float
        between -1.0 for left and 1.0 for right.
        """
        assert -1.0 <= throttle_percentage <= 1.0
        assert -1.0 <= steering_percentage <= 1.0

        if (
            self._throttle == throttle_percentage
            and self._steering == steering_percentage
        ):
            return

        self._telemetry.process_drive_command(
            throttle_percentage,
            steering_percentage
        )
        self._logger.debug(
           'throttle = {throttle}, steering = {steering}'.format(
               throttle=throttle_percentage,
               steering=steering_percentage,
            )
        )
        self._throttle = throttle_percentage
        self._steering = steering_percentage

    def get_throttle(self):
        """Returns the current throttle."""
        return self._throttle

    def get_turn(self):
        """Returns the current turn."""
        return self._steering
Exemple #2
0
class Sup800fTelemetry(threading.Thread):
    """Reader of GPS module that implements the TelemetryData interface."""
    def __init__(self, serial):
        """Create the TelemetryData thread."""
        super(Sup800fTelemetry, self).__init__()
        self.name = self.__class__.__name__

        self._telemetry = TelemetryProducer()
        self._serial = serial
        self._logger = AsyncLogger()
        self._run = True
        self._iterations = 0
        # These initial measurements are from a calibration observation
        self._compass_offsets = (-11.87, -5.97)
        self._magnitude_mean = 353.310
        self._magnitude_std_dev = 117.918

        self._calibrate_compass_end_time = None
        self._nmea_mode = True
        self._last_compass_heading_d = 0.0
        self._dropped_compass_messages = 0
        self._dropped_threshold = 10

        self._hdop = 5.0

        def handle_message(message):
            """Handles command messages. Only cares about calibrate compass;
            other messages are ignored.
            """
            if message == 'calibrate-compass':
                self.calibrate_compass(10)

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

    def run(self):
        """Run in a thread, hands raw telemetry readings to telemetry
        instance.
        """
        failed_to_switch_mode = False
        while self._run:
            try:
                self._run_inner()
            except EnvironmentError as env:
                self._logger.debug('Failed to switch mode: {}'.format(env))
                if not failed_to_switch_mode:
                    failed_to_switch_mode = True
                    import traceback
                    self._logger.debug(traceback.format_exc())
                # Maybe resetting the module mode will help
                try:
                    if self._nmea_mode:
                        switch_to_nmea_mode(self._serial)
                    else:
                        switch_to_binary_mode(self._serial)
                except:
                    pass
            except Exception as exc:  # pylint: disable=broad-except
                self._logger.warn(
                    'Telemetry data caught exception: {}'.format(exc))

    def _run_inner(self):
        """Inner part of run."""
        binary_count = 0
        while self._run:
            if self._calibrate_compass_end_time is not None:
                self._calibrate_compass()

            # NMEA mode
            if self._nmea_mode:
                if self._get_gprc():
                    switch_to_binary_mode(self._serial)
                    self._nmea_mode = False
            else:
                if self._get_binary():
                    binary_count += 1
                    if binary_count >= 3:
                        switch_to_nmea_mode(self._serial)
                        self._nmea_mode = True
                        binary_count = 0

    def _get_gprc(self):
        """Gets and processes a single GPRC message."""
        # This blocks until a new message is received
        line = self._serial.readline()
        try:
            line = line.decode('utf-8')
        except:
            raise EnvironmentError('Not a UTF-8 message')

        if line.startswith('$GPRMC'):
            self._handle_gprmc(line)
            return True
        elif line.startswith('$GPGSA'):
            self._handle_gpgsa(line)
            return True
        return False

    def _get_binary(self):
        """Gets and processes a single binary message."""
        try:
            message = get_message(self._serial, 1000)
        except ValueError:
            self._logger.error('No binary message received')
            return False

        parsed = parse_binary(message)
        if parsed is None:
            return False
        self._handle_binary(parsed)
        return True

    @staticmethod
    def _timestamp(dt):
        """Computes the Unix timestamp from a datetime object. This is needed
        because Python < 3.2 doesn't have .timestamp built in.
        """
        return (dt - datetime.datetime(1970, 1, 1, tzinfo=pytz.utc)) \
            / datetime.timedelta(seconds=1)

    def _handle_gprmc(self, gprmc_message):
        """Handles GPRMC (recommended minimum specific GNSS data) messages."""
        parts = gprmc_message.split(',')
        latitude_str = parts[3]
        longitude_str = parts[5]

        decimal_index = latitude_str.find('.')
        latitude_degrees = float(latitude_str[:decimal_index - 2])
        latitude_minutes = float(latitude_str[decimal_index - 2:])

        decimal_index = longitude_str.find('.')
        longitude_degrees = float(longitude_str[:decimal_index - 2])
        longitude_minutes = float(longitude_str[decimal_index - 2:])

        latitude = latitude_degrees + latitude_minutes / 60.0
        longitude = longitude_degrees + longitude_minutes / 60.0
        if parts[4] == 'S':
            latitude = -latitude
        if parts[6] == 'W':
            longitude = -longitude
        speed_knots = float(parts[7])
        speed_m_s = speed_knots * 0.514444444
        course = float(parts[8])
        # Below a certain speed, the module uses the compass to determine
        # course, which is not calibrated, so we need to use our own value.
        if speed_m_s < COMPASS_SPEED_CUTOFF_M_S:
            course = self._last_compass_heading_d

        time_ = parts[1]
        hours = int(time_[0:2])
        minutes = int(time_[2:4])
        seconds = float(time_[4:])
        date = parts[9]
        day = int(date[0:2])
        month = int(date[2:4])
        year = int(date[4:]) + 2000
        # datetime doesn't do float seconds, so we need to fudge it later
        datetime_ = datetime.datetime(year,
                                      month,
                                      day,
                                      hours,
                                      minutes,
                                      0,
                                      tzinfo=pytz.utc)
        timestamp_s = self._timestamp(datetime_) + seconds

        self._telemetry.gps_reading(
            latitude,
            longitude,
            self._hdop * 5.0,  # This is a guess. Smaller HDOP is more precise.
            course,
            speed_m_s,
            timestamp_s,
            'sup800f')

    def _handle_gpgsa(self, gpgsa_message):
        """Handles GSA (GNSS DOP and active satellites) messages."""
        # $GPGSA,A,3,23,03,26,09,27,16,22,31,,,,,1.9,1.1,1.5*31\r\n
        # type, mode M=manual or A=automatic, fix type 1=N/A 2=2D 3=3D,
        # satellites used 1-12, PDOP, HDOP, VDOP + checksum
        parts = gpgsa_message.split(',')
        #pdop = float(parts[-3])
        hdop = float(parts[-2])
        #vdop = float(parts[-1].split('*')[0])
        self._hdop = hdop

    def _handle_binary(self, message):
        """Handles properietary SUP800F binary messages."""
        if message is None:
            return
        flux_x = float(message.magnetic_flux_ut_x - self._compass_offsets[0])
        flux_y = float(message.magnetic_flux_ut_y - self._compass_offsets[1])
        if flux_x == 0.0:
            # TODO: Figure out what to do here
            return
        self._last_compass_heading_d = Telemetry.wrap_degrees(
            270.0 - math.degrees(math.atan2(flux_y, flux_x)) +
            8.666  # Boulder declination
        )
        magnitude = flux_x**2 + flux_y**2
        std_devs_away = abs(self._magnitude_mean -
                            magnitude) / self._magnitude_std_dev
        # In a normal distribution, 95% of readings should be within 2 std devs
        if std_devs_away > 2.0:
            self._dropped_compass_messages += 1
            if self._dropped_compass_messages > self._dropped_threshold:
                self._logger.warn(
                    'Dropped {} compass messages in a row, std dev = {}'.
                    format(self._dropped_compass_messages,
                           round(std_devs_away, 3)))
                self._dropped_compass_messages = 0
                self._dropped_threshold += 10
            return
        self._dropped_compass_messages = 0
        self._dropped_threshold = 10

        if std_devs_away > 1.0:
            confidence = 2.0 - std_devs_away
        else:
            confidence = 1.0

        self._telemetry.compass_reading(self._last_compass_heading_d,
                                        confidence, 'sup800f')

        self._telemetry.accelerometer_reading(message.acceleration_g_x,
                                              message.acceleration_g_y,
                                              message.acceleration_g_z,
                                              'sup800f')

    def kill(self):
        """Stops any data collection."""
        self._run = False

    def calibrate_compass(self, seconds):
        """Requests that the car calibrate the compasss."""
        if self._calibrate_compass_end_time is None:
            self._calibrate_compass_end_time = time.time() + seconds
        else:
            self._logger.warn('Compass is already being calibrated')

    def _calibrate_compass(self):
        """Calibrates the compass."""
        self._logger.info('Calibrating compass; setting to binary mode')
        switch_to_binary_mode(self._serial)
        self._nmea_mode = False
        for _ in range(10):
            self._serial.readline()

        maxes = [-float('inf')] * 2
        mins = [float('inf')] * 2
        flux_readings = []
        # We should be driving for this long
        while time.time() < self._calibrate_compass_end_time:
            data = get_message(self._serial)
            try:
                binary = parse_binary(data)
            except ValueError as ve:
                self._logger.info(
                    'Unable to parse binary message {}: {}'.format(data, ve))
                continue
            # TODO: This should never be None, see comment in sup800f.py
            if binary is None:
                continue
            flux_values = (
                binary.magnetic_flux_ut_x,
                binary.magnetic_flux_ut_y,
            )
            maxes = [max(a, b) for a, b in zip(maxes, flux_values)]
            mins = [min(a, b) for a, b in zip(mins, flux_values)]
            flux_readings.append(flux_values)

        self._compass_offsets = [(max_ + min_) * 0.5
                                 for max_, min_ in zip(maxes, mins)]
        self._logger.info('Compass calibrated, offsets are {}'.format(
            [round(i, 2) for i in self._compass_offsets]))
        total_magnitudes = numpy.array([(x - self._compass_offsets[0])**2 +
                                        (y - self._compass_offsets[1])**2
                                        for x, y in flux_readings])
        self._magnitude_mean = total_magnitudes.mean()
        self._magnitude_std_dev = total_magnitudes.std()
        self._logger.info('Magnitudes mean: {}, standard deviation: {}'.format(
            round(self._magnitude_mean, 3), round(self._magnitude_std_dev, 3)))

        self._calibrate_compass_end_time = None
        switch_to_nmea_mode(self._serial)
        self._nmea_mode = True
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
Exemple #4
0
class Driver(object):
    """Class that implements the Driver interface."""
    def __init__(self, telemetry):
        self._telemetry = telemetry
        self._logger = AsyncLogger()
        self._throttle = 0.0
        self._steering = 0.0
        self._max_throttle = 1.0

        with open('/dev/pi-blaster', 'w') as blaster:
            blaster.write('{pin}={throttle}\n'.format(
                pin=THROTTLE_GPIO_PIN, throttle=self._get_throttle(0.0)))
            blaster.write('{pin}={steering}\n'.format(
                pin=STEERING_GPIO_PIN, steering=self._get_steering(0.0)))

    def drive(self, throttle_percentage, steering_percentage):
        """Sends a command to the RC car. Throttle should be a float between
        -1.0 for reverse and 1.0 for forward. Turn should be a float between
        -1.0 for left and 1.0 for right.
        """
        assert -1.0 <= throttle_percentage <= 1.0
        assert -1.0 <= steering_percentage <= 1.0
        self._telemetry.process_drive_command(throttle_percentage,
                                              steering_percentage)
        self._logger.debug('throttle = {throttle}, turn = {turn}'.format(
            throttle=throttle_percentage,
            turn=steering_percentage,
        ))
        self._throttle = throttle_percentage
        self._steering = steering_percentage

        self._logger.debug('Throttle: {}, steering: {}'.format(
            throttle_percentage, steering_percentage))

        if throttle_percentage > 0.0:
            throttle = min(self._max_throttle, throttle_percentage)
        else:
            # Reverse is slower than forward, so allow 2x
            throttle = max(-2 * self._max_throttle, throttle_percentage, -1.0)

        with open('/dev/pi-blaster', 'w') as blaster:
            blaster.write('{pin}={throttle}\n'.format(
                pin=THROTTLE_GPIO_PIN, throttle=self._get_throttle(throttle)))
            blaster.write('{pin}={steering}\n'.format(
                pin=STEERING_GPIO_PIN,
                steering=self._get_steering(steering_percentage)))

    def get_throttle(self):
        """Returns the current throttle."""
        return self._throttle

    def get_turn(self):
        """Returns the current turn."""
        return self._steering

    @staticmethod
    def _get_throttle(percentage):
        """Returns the throttle value."""
        # Purposely limit the reverse in case we try to go back while still
        # rolling - prevent damage to the gear box
        if not (-0.5 <= percentage <= 1.0):
            raise ValueError('Bad throttle: {}'.format(percentage))
        return round(
            (THROTTLE_NEUTRAL_US + THROTTLE_DIFF * percentage) * 0.0001, 3)

    @staticmethod
    def _get_steering(percentage):
        """Returns the steering value."""
        if not (-1.0 <= percentage <= 1.0):
            raise ValueError('Bad steering')
        return round(
            (STEERING_NEUTRAL_US + STEERING_DIFF * percentage) * 0.0001, 3)

    def set_max_throttle(self, max_throttle):
        """Sets the maximum throttle."""
        self._max_throttle = min(1.0, max_throttle)
Exemple #5
0
class Command(threading.Thread):  # pylint: disable=too-many-instance-attributes
    """Processes telemetry data and controls the RC car."""
    VALID_COMMANDS = {'start', 'stop', 'reset', 'calibrate-compass'}
    NEUTRAL_TIME_1_S = 1.0
    REVERSE_TIME_S = 0.25
    NEUTRAL_TIME_2_S = 0.25
    NEUTRAL_TIME_3_S = 1.0
    STRAIGHT_TIME_S = 8.0

    def __init__(
            self,
            telemetry,
            driver,
            waypoint_generator,
            sleep_time_milliseconds=None,
    ):
        """Create the Command thread."""
        super(Command, self).__init__()
        self.name = self.__class__.__name__

        self._telemetry = telemetry
        if sleep_time_milliseconds is None:
            self._sleep_time_seconds = .02
        else:
            self._sleep_time_seconds = sleep_time_milliseconds / 1000.0
        self._driver = driver
        self._logger = AsyncLogger()
        self._forwarder = CommandForwardProducer()
        self._run = True
        self._run_course = False
        self._waypoint_generator = waypoint_generator

        self._sleep_time = None
        self._wake_time = None
        self._start_time = None

        self._camera = picamera.PiCamera()

        # If the car is on the starting line (not started yet)
        self._on_starting_line = True

        self._commands = queue.Queue()
        def callback(message):
            self._commands.put(message)
            # I never could figure out how to do multi consumer exchanges, and
            # I only need it for two consumers... so, just forward the message
            # on to the one place it needs to go
            self._forwarder.forward(message)

        consume = lambda: consume_messages(config.COMMAND_EXCHANGE, callback)
        self._thread = threading.Thread(target=consume)
        self._thread.name = '{}:consume_messages'.format(
            self.__class__.__name__
        )
        self._thread.start()

    def _handle_message(self, command):
        """Handles command messages, e.g. 'start' or 'stop'."""
        if command not in self.VALID_COMMANDS:
            if command.startswith('set-max-throttle'):
                try:
                    max_throttle = float(command.split('=')[1])
                    self.set_max_throttle(max_throttle)
                    return
                except Exception as exc: # pylint: disable=broad-except
                    self._logger.error(
                        'Bad throttle command: "{command}": {exc}'.format(
                            command=command,
                            exc=exc,
                        )
                    )
                    return
            self._logger.error(
                'Unknown command: "{command}"'.format(
                    command=command
                )
            )
            return

        if command == 'start':
            try:
                now = datetime.datetime.now()
                file_name = '/data/{}-{}-{}_{}:{}:{}.h264'.format(
                    now.year,
                    now.month,
                    now.day,
                    now.hour,
                    now.minute,
                    now.second
                )
                self._camera.start_recording(file_name)
            except Exception as exc:  # pylint: disable=broad-except
                self._logger.warning('Unable to save video: {}'.format(exc))
            self.run_course()

        elif command == 'stop':
            threading.Thread(target=self._stop_recording).start()
            self.stop()

        elif command == 'reset':
            self._on_starting_line = True
            self.reset()

        elif command == 'calibrate-compass':
            self.calibrate_compass(10)

    def _wait(self):
        """We just define this function separately so that it's easy to patch
        when testing.
        """
        self._sleep_time = time.time()
        if self._wake_time is not None:
            time_awake = self._sleep_time - self._wake_time
        else:
            time_awake = 0.0
        time.sleep(max(self._sleep_time_seconds - time_awake, 0.0))
        self._wake_time = time.time()

    def run(self):
        """Run in a thread, controls the RC car."""
        error_count = 0
        if self._waypoint_generator.done():
            threading.Thread(target=self._stop_recording).start()
            self._logger.info('All waypoints reached')
            return

        while self._run:
            try:
                while self._run and not self._run_course:
                    while not self._commands.empty():
                        self._handle_message(self._commands.get())
                    if self._telemetry.is_inverted():
                        self._logger.info('Car is inverted, starting in 3')

                        def inverted_start():
                            while self._telemetry.is_inverted():
                                time.sleep(0.25)
                            self._logger.info('Starting in 3 seconds')
                            time.sleep(3)
                            if not self._run_course:
                                self._handle_message('start')
                            else:
                                self._logger.info('Inverted start aborted')

                        threading.Thread(target=inverted_start).start()

                    self._wait()

                if not self._run:
                    return

                # If we are on the starting line
                if self._on_starting_line:
                    self._on_starting_line = False
                    self._logger.info(
                        'Driving straight for {} seconds'.format(
                            self.STRAIGHT_TIME_S
                        )
                    )
                    straight_iterator = self._straight_iterator()
                    while (
                            self._run
                            and self._run_course
                            and next(straight_iterator)
                    ):
                        telemetry = self._telemetry.get_data()
                        if self._waypoint_generator.reached(
                                telemetry['x_m'],
                                telemetry['y_m']
                        ):
                            self._logger.info('Reached waypoint')
                            self._waypoint_generator.next()
                        self._driver.drive(1.0, 0.0)
                        self._wait()

                self._logger.info('Running course iteration')
                run_iterator = self._run_iterator()
                while self._run and self._run_course and next(run_iterator):
                    while not self._commands.empty():
                        self._handle_message(self._commands.get())
                    self._wait()

                self._logger.info('Stopping course')
                self._driver.drive(0.0, 0.0)

            except Exception as exception:  # pylint: disable=broad-except
                trace_back = sys.exc_info()[2]
                traces = traceback.extract_tb(trace_back)

                # Find the last local file
                for index in range(len(traces) - 1, -1, -1):
                    file_name, line_number, function_name, _ = traces[index]
                    if file_name.endswith('.py'):
                        break

                trace = '{file_}:{line} {function}'.format(
                    file_=file_name,
                    line=line_number,
                    function=function_name
                )
                self._logger.warning(
                    'Command thread had exception from {trace}, ignoring:'
                    ' {type_}:{message}'.format(
                        trace=trace,
                        type_=str(type(exception)),
                        message=str(exception),
                    )
                )
                error_count += 1
                if error_count > 10:
                    self._logger.warning('Too many exceptions, pausing')
                    self.stop()

                    for _ in range(10):
                        # If we want to kill the thread or continue running the
                        # course again, then stop the pause
                        if not self._run or self._run_course:
                            break
                        time.sleep(0.5)

                    self.run_course()
                    self._logger.warning('Restarting after pause')
                    error_count = 0

    def _straight_iterator(self, seconds=None):
        """Runs straight for a little bit."""
        if seconds == None:
            seconds = self.STRAIGHT_TIME_S
        start_s = time.time()
        while time.time() - start_s < seconds:
            yield True
        yield False

    def _run_iterator(self):
        """Returns an iterator that drives everything."""
        course_iterator = self._run_course_iterator()
        while True:
            if (
                    self._telemetry.is_stopped()
                    and self._start_time is not None
                    and time.time() - self._start_time > 2.0
            ):
                self._logger.info(
                    'RC car is not moving according to speed history, reversing'
                )

                unstuck_iterator = self._unstuck_yourself_iterator(1.0)

                while next(unstuck_iterator):
                    yield True

                # Force the car to drive for a little while
                start = time.time()
                self._start_time = start
                while (
                        self._run
                        and self._run_course
                        and time.time() < start + 2.0
                        and next(course_iterator)
                ):
                    yield True

            yield next(course_iterator)

    def _run_course_iterator(self):
        """Runs a single iteration of the course navigation loop."""
        while not self._waypoint_generator.done():
            telemetry = self._telemetry.get_data()
            current_waypoint = self._waypoint_generator.get_current_waypoint(
                telemetry['x_m'],
                telemetry['y_m']
            )

            distance_m = math.sqrt(
                (telemetry['x_m'] - current_waypoint[0]) ** 2
                + (telemetry['y_m'] - current_waypoint[1]) ** 2
            )

            self._logger.debug(
                'Distance to goal {waypoint}: {distance}'.format(
                    waypoint=[round(i, 3) for i in current_waypoint],
                    distance=round(distance_m, 3),
                )
            )
            # We let the waypoint generator tell us if a waypoint has been
            # reached so that it can do fancy algorithms, like "rabbit chase"
            if self._waypoint_generator.reached(
                    telemetry['x_m'],
                    telemetry['y_m']
            ):
                self._logger.info(
                    'Reached {}'.format(
                        [round(i, 3) for i in current_waypoint]
                    )
                )
                self._waypoint_generator.next()
                continue

            if distance_m > 10.0 or distance_m / telemetry['speed_m_s'] > 2.0:
                throttle = 1.0
            else:
                throttle = 0.5

            degrees = Telemetry.relative_degrees(
                telemetry['x_m'],
                telemetry['y_m'],
                current_waypoint[0],
                current_waypoint[1]
            )

            heading_d = telemetry['heading_d']

            self._logger.debug(
                'My heading: {my_heading}, goal heading: {goal_heading}'.format(
                    my_heading=round(heading_d, 3),
                    goal_heading=round(degrees, 3),
                )
            )

            diff_d = Telemetry.difference_d(degrees, heading_d)
            if diff_d < 10.0:
                # We want to keep turning until we pass the point
                is_left = Telemetry.is_turn_left(heading_d, degrees)
                while diff_d < 10.0:
                    yield True
                    telemetry = self._telemetry.get_data()
                    degrees = Telemetry.relative_degrees(
                        telemetry['x_m'],
                        telemetry['y_m'],
                        current_waypoint[0],
                        current_waypoint[1]
                    )
                    heading_d = telemetry['heading_d']
                    diff_d = Telemetry.difference_d(degrees, heading_d)

                    if self._waypoint_generator.reached(
                            telemetry['x_m'],
                            telemetry['y_m']
                    ):
                        self._logger.info(
                            'Reached {}'.format(
                                [round(i, 3) for i in current_waypoint]
                            )
                        )
                        self._waypoint_generator.next()
                        break
                    if Telemetry.is_turn_left(heading_d, degrees) != is_left:
                        break

                self._driver.drive(throttle, 0.0)
                yield True
                continue
            # No sharp turns when we are close, to avoid hard swerves
            elif distance_m < 3.0:
                turn = 0.25
            elif diff_d > 90.0:
                turn = 1.0
            elif diff_d > 45.0:
                turn = 0.5
            else:
                turn = 0.25

            # Turning while going fast causes the car to roll over
            if telemetry['speed_m_s'] > 7.0 or throttle >= 0.75:
                turn = max(turn, 0.25)
            elif telemetry['speed_m_s'] > 4.0 or throttle >= 0.5:
                turn = max(turn, 0.5)

            if Telemetry.is_turn_left(heading_d, degrees):
                turn = -turn
            self._driver.drive(throttle, turn)
            yield True

        self._logger.info('No waypoints, stopping')
        self._driver.drive(0.0, 0.0)
        self.stop()
        yield False

    def calibrate_compass(self, seconds):
        """Calibrates the compass."""
        # Don't calibrate while driving
        if self._run_course:
            self._logger.warn("Can't configure compass while running")
            return

        start = time.time()
        self._driver.drive(0.5, 1.0)
        try:
            while (
                    self._run
                    and not self._run_course
                    and time.time() < start + seconds
            ):
                time.sleep(0.1)
        except:  # pylint: disable=bare-except
            pass
        self._driver.drive(0.0, 0.0)

    def set_max_throttle(self, throttle):
        """Sets the maximum throttle speed."""
        self._driver.set_max_throttle(throttle)

    def run_course(self):
        """Starts the RC car running the course."""
        self._run_course = True
        self._start_time = time.time()

    def stop(self):
        """Stops the RC car from running the course."""
        self._driver.drive(0.0, 0.0)
        self._run_course = False

    def reset(self):
        """Resets the waypoints for the RC car."""
        if self.is_running_course():
            self._logger.warn('Tried to reset the course while running')
            return
        self._waypoint_generator.reset()

    def kill(self):
        """Kills the thread."""
        self._run = False

    def is_running_course(self):
        """Returns True if we're currently navigating the course."""
        return self._run_course

    def _unstuck_yourself_iterator(self, seconds):
        """Commands the car to reverse and try to get off an obstacle."""
        # The ESC requires us to send neutral throttle for a bit, then send
        # reverse, then neutral, then reverse again (which will actually drive
        # the car in reverse)

        start = time.time()
        while time.time() < start + self.NEUTRAL_TIME_1_S:
            self._driver.drive(0.0, 0.0)
            yield True

        start = time.time()
        while time.time() < start + self.REVERSE_TIME_S:
            self._driver.drive(-0.5, 0.0)
            yield True

        start = time.time()
        while time.time() < start + self.NEUTRAL_TIME_2_S:
            self._driver.drive(0.0, 0.0)
            yield True

        telemetry = self._telemetry.get_data()
        heading_d = telemetry['heading_d']
        current_waypoint = self._waypoint_generator.get_current_waypoint(
            telemetry['x_m'],
            telemetry['y_m']
        )
        degrees = Telemetry.relative_degrees(
            telemetry['x_m'],
            telemetry['y_m'],
            current_waypoint[0],
            current_waypoint[1]
        )
        is_left = Telemetry.is_turn_left(heading_d, degrees)
        if is_left is None:
            turn_direction = 1.0 if random.randint(0, 1) == 0 else -1.0
        elif is_left:
            turn_direction = 1.0  # Reversed because we're driving in reverse
        else:
            turn_direction = -1.0

        start = time.time()
        while time.time() < start + seconds:
            self._driver.drive(-.5, turn_direction)
            yield True

        # Pause for a bit; jamming from reverse to drive is a bad idea
        start = time.time()
        while time.time() < start + self.NEUTRAL_TIME_3_S:
            self._driver.drive(0.0, 0.0)
            yield True

        yield False

    def _stop_recording(self):
        """Stops recording after a little while."""
        time.sleep(5)
        self._camera.stop_recording()
class Sup800fTelemetry(threading.Thread):
    """Reader of GPS module that implements the TelemetryData interface."""
    def __init__(self, serial):
        """Create the TelemetryData thread."""
        super(Sup800fTelemetry, self).__init__()
        self.name = self.__class__.__name__

        self._telemetry = TelemetryProducer()
        self._serial = serial
        self._logger = AsyncLogger()
        self._run = True
        self._iterations = 0
        # These initial measurements are from a calibration observation
        self._compass_offsets = (-11.87, -5.97)
        self._magnitude_mean = 353.310
        self._magnitude_std_dev = 117.918

        self._calibrate_compass_end_time = None
        self._nmea_mode = True
        self._last_compass_heading_d = 0.0
        self._dropped_compass_messages = 0
        self._dropped_threshold = 10

        self._hdop = 5.0

        def handle_message(message):
            """Handles command messages. Only cares about calibrate compass;
            other messages are ignored.
            """
            if message == 'calibrate-compass':
                self.calibrate_compass(10)

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

    def run(self):
        """Run in a thread, hands raw telemetry readings to telemetry
        instance.
        """
        failed_to_switch_mode = False
        while self._run:
            try:
                self._run_inner()
            except EnvironmentError as env:
                self._logger.debug('Failed to switch mode: {}'.format(env))
                if not failed_to_switch_mode:
                    failed_to_switch_mode = True
                    import traceback
                    self._logger.debug(traceback.format_exc())
                # Maybe resetting the module mode will help
                try:
                    if self._nmea_mode:
                        switch_to_nmea_mode(self._serial)
                    else:
                        switch_to_binary_mode(self._serial)
                except:
                    pass
            except Exception as exc:  # pylint: disable=broad-except
                self._logger.warn(
                    'Telemetry data caught exception: {}'.format(
                        exc
                    )
                )

    def _run_inner(self):
        """Inner part of run."""
        binary_count = 0
        while self._run:
            if self._calibrate_compass_end_time is not None:
                self._calibrate_compass()

            # NMEA mode
            if self._nmea_mode:
                if self._get_gprc():
                    switch_to_binary_mode(self._serial)
                    self._nmea_mode = False
            else:
                if self._get_binary():
                    binary_count += 1
                    if binary_count >= 3:
                        switch_to_nmea_mode(self._serial)
                        self._nmea_mode = True
                        binary_count = 0

    def _get_gprc(self):
        """Gets and processes a single GPRC message."""
        # This blocks until a new message is received
        line = self._serial.readline()
        try:
            line = line.decode('utf-8')
        except:
            raise EnvironmentError('Not a UTF-8 message')

        if line.startswith('$GPRMC'):
            self._handle_gprmc(line)
            return True
        elif line.startswith('$GPGSA'):
            self._handle_gpgsa(line)
            return True
        return False

    def _get_binary(self):
        """Gets and processes a single binary message."""
        try:
            message = get_message(self._serial, 1000)
        except ValueError:
            self._logger.error('No binary message received')
            return False

        parsed = parse_binary(message)
        if parsed is None:
            return False
        self._handle_binary(parsed)
        return True

    @staticmethod
    def _timestamp(dt):
        """Computes the Unix timestamp from a datetime object. This is needed
        because Python < 3.2 doesn't have .timestamp built in.
        """
        return (dt - datetime.datetime(1970, 1, 1, tzinfo=pytz.utc)) \
            / datetime.timedelta(seconds=1)

    def _handle_gprmc(self, gprmc_message):
        """Handles GPRMC (recommended minimum specific GNSS data) messages."""
        parts = gprmc_message.split(',')
        latitude_str = parts[3]
        longitude_str = parts[5]

        decimal_index = latitude_str.find('.')
        latitude_degrees = float(latitude_str[:decimal_index - 2])
        latitude_minutes = float(latitude_str[decimal_index - 2:])

        decimal_index = longitude_str.find('.')
        longitude_degrees = float(longitude_str[:decimal_index - 2])
        longitude_minutes = float(longitude_str[decimal_index - 2:])

        latitude = latitude_degrees + latitude_minutes / 60.0
        longitude = longitude_degrees + longitude_minutes / 60.0
        if parts[4] == 'S':
            latitude = -latitude
        if parts[6] == 'W':
            longitude = -longitude
        speed_knots = float(parts[7])
        speed_m_s = speed_knots * 0.514444444
        course = float(parts[8])
        # Below a certain speed, the module uses the compass to determine
        # course, which is not calibrated, so we need to use our own value.
        if speed_m_s < COMPASS_SPEED_CUTOFF_M_S:
            course = self._last_compass_heading_d

        time_ = parts[1]
        hours = int(time_[0:2])
        minutes = int(time_[2:4])
        seconds = float(time_[4:])
        date = parts[9]
        day = int(date[0:2])
        month = int(date[2:4])
        year = int(date[4:]) + 2000
        # datetime doesn't do float seconds, so we need to fudge it later
        datetime_ = datetime.datetime(
            year,
            month,
            day,
            hours,
            minutes,
            0,
            tzinfo=pytz.utc
        )
        timestamp_s = self._timestamp(datetime_) + seconds

        self._telemetry.gps_reading(
            latitude,
            longitude,
            self._hdop * 5.0,  # This is a guess. Smaller HDOP is more precise.
            course,
            speed_m_s,
            timestamp_s,
            'sup800f'
        )

    def _handle_gpgsa(self, gpgsa_message):
        """Handles GSA (GNSS DOP and active satellites) messages."""
        # $GPGSA,A,3,23,03,26,09,27,16,22,31,,,,,1.9,1.1,1.5*31\r\n
        # type, mode M=manual or A=automatic, fix type 1=N/A 2=2D 3=3D,
        # satellites used 1-12, PDOP, HDOP, VDOP + checksum
        parts = gpgsa_message.split(',')
        #pdop = float(parts[-3])
        hdop = float(parts[-2])
        #vdop = float(parts[-1].split('*')[0])
        self._hdop = hdop

    def _handle_binary(self, message):
        """Handles properietary SUP800F binary messages."""
        if message is None:
            return
        flux_x = float(message.magnetic_flux_ut_x - self._compass_offsets[0])
        flux_y = float(message.magnetic_flux_ut_y - self._compass_offsets[1])
        if flux_x == 0.0:
            # TODO: Figure out what to do here
            return
        self._last_compass_heading_d = Telemetry.wrap_degrees(
            270.0 - math.degrees(
                math.atan2(flux_y, flux_x)
            ) + 8.666  # Boulder declination
        )
        magnitude = flux_x ** 2 + flux_y ** 2
        std_devs_away = abs(
            self._magnitude_mean - magnitude
        ) / self._magnitude_std_dev
        # In a normal distribution, 95% of readings should be within 2 std devs
        if std_devs_away > 2.0:
            self._dropped_compass_messages += 1
            if self._dropped_compass_messages > self._dropped_threshold:
                self._logger.warn(
                    'Dropped {} compass messages in a row, std dev = {}'.format(
                        self._dropped_compass_messages,
                        round(std_devs_away, 3)
                    )
                )
                self._dropped_compass_messages = 0
                self._dropped_threshold += 10
            return
        self._dropped_compass_messages = 0
        self._dropped_threshold = 10

        if std_devs_away > 1.0:
            confidence = 2.0 - std_devs_away
        else:
            confidence = 1.0

        self._telemetry.compass_reading(
            self._last_compass_heading_d,
            confidence,
            'sup800f'
        )

        self._telemetry.accelerometer_reading(
            message.acceleration_g_x,
            message.acceleration_g_y,
            message.acceleration_g_z,
            'sup800f'
        )

    def kill(self):
        """Stops any data collection."""
        self._run = False

    def calibrate_compass(self, seconds):
        """Requests that the car calibrate the compasss."""
        if self._calibrate_compass_end_time is None:
            self._calibrate_compass_end_time = time.time() + seconds
        else:
            self._logger.warn('Compass is already being calibrated')

    def _calibrate_compass(self):
        """Calibrates the compass."""
        self._logger.info('Calibrating compass; setting to binary mode')
        switch_to_binary_mode(self._serial)
        self._nmea_mode = False
        for _ in range(10):
            self._serial.readline()

        maxes = [-float('inf')] * 2
        mins = [float('inf')] * 2
        flux_readings = []
        # We should be driving for this long
        while time.time() < self._calibrate_compass_end_time:
            data = get_message(self._serial)
            try:
                binary = parse_binary(data)
            except ValueError as ve:
                self._logger.info(
                    'Unable to parse binary message {}: {}'.format(
                        data,
                        ve
                    )
                )
                continue
            # TODO: This should never be None, see comment in sup800f.py
            if binary is None:
                continue
            flux_values = (
                binary.magnetic_flux_ut_x,
                binary.magnetic_flux_ut_y,
            )
            maxes = [max(a, b) for a, b in zip(maxes, flux_values)]
            mins = [min(a, b) for a, b in zip(mins, flux_values)]
            flux_readings.append(flux_values)

        self._compass_offsets = [
            (max_ + min_) * 0.5 for max_, min_ in zip(maxes, mins)
        ]
        self._logger.info(
            'Compass calibrated, offsets are {}'.format(
                [round(i, 2) for i in self._compass_offsets]
            )
        )
        total_magnitudes = numpy.array([
            (x - self._compass_offsets[0]) ** 2 +
            (y - self._compass_offsets[1]) ** 2
            for x, y in flux_readings
        ])
        self._magnitude_mean = total_magnitudes.mean()
        self._magnitude_std_dev = total_magnitudes.std()
        self._logger.info(
            'Magnitudes mean: {}, standard deviation: {}'.format(
                round(self._magnitude_mean, 3),
                round(self._magnitude_std_dev, 3)
            )
        )

        self._calibrate_compass_end_time = None
        switch_to_nmea_mode(self._serial)
        self._nmea_mode = True
def main():
    """Sets up logging, signal handling, etc. and starts the threads."""
    signal.signal(signal.SIGINT, terminate)

    parser = make_parser()
    args = parser.parse_args()

    #try:
    #    global POPEN
    #    POPEN = subprocess.Popen((
    #        'raspivid', '-o', args.video, '-w', '1024', '-h', '576', '-b', '6000000', '-t', '300000'
    #    ))
    #except Exception:
    #    logging.warning('Unable to save video')

    concrete_logger = logging.Logger('sparkfun')
    concrete_logger.setLevel(logging.DEBUG)
    formatter = logging.Formatter(
        '%(asctime)s:%(levelname)s %(message)s'
    )

    file_handler = None
    try:
        if os.path.exists(args.log):
            os.remove(args.log)
        file_handler = logging.FileHandler(args.log)
        file_handler.setFormatter(formatter)
        file_handler.setLevel(logging.DEBUG)
        concrete_logger.addHandler(file_handler)
        try:
            last_log = os.path.dirname(args.log) + os.sep + 'last-log.txt'
            with open(last_log, 'a') as last_log:
                last_log.write(args.log + '\n')
        except Exception as exc:
            print('Unable to save last log information: {}'.format(exc))
    except Exception as exception:
        logging.warning('Could not create file log: ' + str(exception))

    stdout_handler = logging.StreamHandler(sys.stdout)
    if args.verbose:
        stdout_handler.setLevel(logging.DEBUG)
    else:
        stdout_handler.setLevel(logging.INFO)
    stdout_handler.setFormatter(formatter)
    concrete_logger.addHandler(stdout_handler)

    async_logger = AsyncLoggerReceiver(concrete_logger)
    # We need to start async_logger now so that other people can log to it
    async_logger.start()
    time.sleep(0.1)
    THREADS.append(async_logger)

    web_socket_handler = WebSocketLoggingHandler()
    web_socket_handler.setLevel(logging.INFO)
    web_socket_handler.setFormatter(formatter)
    concrete_logger.addHandler(web_socket_handler)

    logger = AsyncLogger()

    if sys.version_info.major < 3:
        logger.warn(
            'Python 2 is not officially supported, use at your own risk'
        )

    kml_file = args.kml_file
    if kml_file is None:
        logger.info(
            'Setting waypoints to Solid State Depot for testing'
        )
        kml_file = 'solid-state-depot.kml'
    if args.chase:
        waypoint_generator = ChaseWaypointGenerator(
            SimpleWaypointGenerator.get_waypoints_from_file_name(
                kml_file
            )
        )
    else:
        waypoint_generator = ExtensionWaypointGenerator(
            SimpleWaypointGenerator.get_waypoints_from_file_name(
                kml_file
            )
        )

    logger.debug('Calling start_threads')

    start_threads(
        waypoint_generator,
        logger,
        web_socket_handler,
        args.max_throttle,
        kml_file,
    )
class Command(threading.Thread):  # pylint: disable=too-many-instance-attributes
    """Processes telemetry data and controls the RC car."""
    VALID_COMMANDS = {'start', 'stop', 'reset', 'calibrate-compass'}
    NEUTRAL_TIME_1_S = 1.0
    REVERSE_TIME_S = 0.25
    NEUTRAL_TIME_2_S = 0.25
    NEUTRAL_TIME_3_S = 1.0
    STRAIGHT_TIME_S = 8.0

    def __init__(
        self,
        telemetry,
        driver,
        waypoint_generator,
        sleep_time_milliseconds=None,
    ):
        """Create the Command thread."""
        super(Command, self).__init__()
        self.name = self.__class__.__name__

        self._telemetry = telemetry
        if sleep_time_milliseconds is None:
            self._sleep_time_seconds = .02
        else:
            self._sleep_time_seconds = sleep_time_milliseconds / 1000.0
        self._driver = driver
        self._logger = AsyncLogger()
        self._forwarder = CommandForwardProducer()
        self._run = True
        self._run_course = False
        self._waypoint_generator = waypoint_generator

        self._sleep_time = None
        self._wake_time = None
        self._start_time = None

        self._camera = picamera.PiCamera()

        # If the car is on the starting line (not started yet)
        self._on_starting_line = True

        self._commands = queue.Queue()

        def callback(message):
            self._commands.put(message)
            # I never could figure out how to do multi consumer exchanges, and
            # I only need it for two consumers... so, just forward the message
            # on to the one place it needs to go
            self._forwarder.forward(message)

        consume = lambda: consume_messages(config.COMMAND_EXCHANGE, callback)
        self._thread = threading.Thread(target=consume)
        self._thread.name = '{}:consume_messages'.format(
            self.__class__.__name__)
        self._thread.start()

    def _handle_message(self, command):
        """Handles command messages, e.g. 'start' or 'stop'."""
        if command not in self.VALID_COMMANDS:
            if command.startswith('set-max-throttle'):
                try:
                    max_throttle = float(command.split('=')[1])
                    self.set_max_throttle(max_throttle)
                    return
                except Exception as exc:  # pylint: disable=broad-except
                    self._logger.error(
                        'Bad throttle command: "{command}": {exc}'.format(
                            command=command,
                            exc=exc,
                        ))
                    return
            self._logger.error(
                'Unknown command: "{command}"'.format(command=command))
            return

        if command == 'start':
            try:
                now = datetime.datetime.now()
                file_name = '/data/{}-{}-{}_{}:{}:{}.h264'.format(
                    now.year, now.month, now.day, now.hour, now.minute,
                    now.second)
                self._camera.start_recording(file_name)
            except Exception as exc:  # pylint: disable=broad-except
                self._logger.warning('Unable to save video: {}'.format(exc))
            self.run_course()

        elif command == 'stop':
            threading.Thread(target=self._stop_recording).start()
            self.stop()

        elif command == 'reset':
            self._on_starting_line = True
            self.reset()

        elif command == 'calibrate-compass':
            self.calibrate_compass(10)

    def _wait(self):
        """We just define this function separately so that it's easy to patch
        when testing.
        """
        self._sleep_time = time.time()
        if self._wake_time is not None:
            time_awake = self._sleep_time - self._wake_time
        else:
            time_awake = 0.0
        time.sleep(max(self._sleep_time_seconds - time_awake, 0.0))
        self._wake_time = time.time()

    def run(self):
        """Run in a thread, controls the RC car."""
        error_count = 0
        if self._waypoint_generator.done():
            threading.Thread(target=self._stop_recording).start()
            self._logger.info('All waypoints reached')
            return

        while self._run:
            try:
                while self._run and not self._run_course:
                    while not self._commands.empty():
                        self._handle_message(self._commands.get())
                    if self._telemetry.is_inverted():
                        self._logger.info('Car is inverted, starting in 3')

                        def inverted_start():
                            while self._telemetry.is_inverted():
                                time.sleep(0.25)
                            self._logger.info('Starting in 3 seconds')
                            time.sleep(3)
                            if not self._run_course:
                                self._handle_message('start')
                            else:
                                self._logger.info('Inverted start aborted')

                        threading.Thread(target=inverted_start).start()

                    self._wait()

                if not self._run:
                    return

                # If we are on the starting line
                if self._on_starting_line:
                    self._on_starting_line = False
                    self._logger.info('Driving straight for {} seconds'.format(
                        self.STRAIGHT_TIME_S))
                    straight_iterator = self._straight_iterator()
                    while (self._run and self._run_course
                           and next(straight_iterator)):
                        telemetry = self._telemetry.get_data()
                        if self._waypoint_generator.reached(
                                telemetry['x_m'], telemetry['y_m']):
                            self._logger.info('Reached waypoint')
                            self._waypoint_generator.next()
                        self._driver.drive(1.0, 0.0)
                        self._wait()

                self._logger.info('Running course iteration')
                run_iterator = self._run_iterator()
                while self._run and self._run_course and next(run_iterator):
                    while not self._commands.empty():
                        self._handle_message(self._commands.get())
                    self._wait()

                self._logger.info('Stopping course')
                self._driver.drive(0.0, 0.0)

            except Exception as exception:  # pylint: disable=broad-except
                trace_back = sys.exc_info()[2]
                traces = traceback.extract_tb(trace_back)

                # Find the last local file
                for index in range(len(traces) - 1, -1, -1):
                    file_name, line_number, function_name, _ = traces[index]
                    if file_name.endswith('.py'):
                        break

                trace = '{file_}:{line} {function}'.format(
                    file_=file_name, line=line_number, function=function_name)
                self._logger.warning(
                    'Command thread had exception from {trace}, ignoring:'
                    ' {type_}:{message}'.format(
                        trace=trace,
                        type_=str(type(exception)),
                        message=str(exception),
                    ))
                error_count += 1
                if error_count > 10:
                    self._logger.warning('Too many exceptions, pausing')
                    self.stop()

                    for _ in range(10):
                        # If we want to kill the thread or continue running the
                        # course again, then stop the pause
                        if not self._run or self._run_course:
                            break
                        time.sleep(0.5)

                    self.run_course()
                    self._logger.warning('Restarting after pause')
                    error_count = 0

    def _straight_iterator(self, seconds=None):
        """Runs straight for a little bit."""
        if seconds == None:
            seconds = self.STRAIGHT_TIME_S
        start_s = time.time()
        while time.time() - start_s < seconds:
            yield True
        yield False

    def _run_iterator(self):
        """Returns an iterator that drives everything."""
        course_iterator = self._run_course_iterator()
        while True:
            if (self._telemetry.is_stopped() and self._start_time is not None
                    and time.time() - self._start_time > 2.0):
                self._logger.info(
                    'RC car is not moving according to speed history, reversing'
                )

                unstuck_iterator = self._unstuck_yourself_iterator(1.0)

                while next(unstuck_iterator):
                    yield True

                # Force the car to drive for a little while
                start = time.time()
                self._start_time = start
                while (self._run and self._run_course
                       and time.time() < start + 2.0
                       and next(course_iterator)):
                    yield True

            yield next(course_iterator)

    def _run_course_iterator(self):
        """Runs a single iteration of the course navigation loop."""
        while not self._waypoint_generator.done():
            telemetry = self._telemetry.get_data()
            current_waypoint = self._waypoint_generator.get_current_waypoint(
                telemetry['x_m'], telemetry['y_m'])

            distance_m = math.sqrt((telemetry['x_m'] -
                                    current_waypoint[0])**2 +
                                   (telemetry['y_m'] - current_waypoint[1])**2)

            self._logger.debug(
                'Distance to goal {waypoint}: {distance}'.format(
                    waypoint=[round(i, 3) for i in current_waypoint],
                    distance=round(distance_m, 3),
                ))
            # We let the waypoint generator tell us if a waypoint has been
            # reached so that it can do fancy algorithms, like "rabbit chase"
            if self._waypoint_generator.reached(telemetry['x_m'],
                                                telemetry['y_m']):
                self._logger.info('Reached {}'.format(
                    [round(i, 3) for i in current_waypoint]))
                self._waypoint_generator.next()
                continue

            if distance_m > 10.0 or distance_m / telemetry['speed_m_s'] > 2.0:
                throttle = 1.0
            else:
                throttle = 0.5

            degrees = Telemetry.relative_degrees(telemetry['x_m'],
                                                 telemetry['y_m'],
                                                 current_waypoint[0],
                                                 current_waypoint[1])

            heading_d = telemetry['heading_d']

            self._logger.debug(
                'My heading: {my_heading}, goal heading: {goal_heading}'.
                format(
                    my_heading=round(heading_d, 3),
                    goal_heading=round(degrees, 3),
                ))

            diff_d = Telemetry.difference_d(degrees, heading_d)
            if diff_d < 10.0:
                # We want to keep turning until we pass the point
                is_left = Telemetry.is_turn_left(heading_d, degrees)
                while diff_d < 10.0:
                    yield True
                    telemetry = self._telemetry.get_data()
                    degrees = Telemetry.relative_degrees(
                        telemetry['x_m'], telemetry['y_m'],
                        current_waypoint[0], current_waypoint[1])
                    heading_d = telemetry['heading_d']
                    diff_d = Telemetry.difference_d(degrees, heading_d)

                    if self._waypoint_generator.reached(
                            telemetry['x_m'], telemetry['y_m']):
                        self._logger.info('Reached {}'.format(
                            [round(i, 3) for i in current_waypoint]))
                        self._waypoint_generator.next()
                        break
                    if Telemetry.is_turn_left(heading_d, degrees) != is_left:
                        break

                self._driver.drive(throttle, 0.0)
                yield True
                continue
            # No sharp turns when we are close, to avoid hard swerves
            elif distance_m < 3.0:
                turn = 0.25
            elif diff_d > 90.0:
                turn = 1.0
            elif diff_d > 45.0:
                turn = 0.5
            else:
                turn = 0.25

            # Turning while going fast causes the car to roll over
            if telemetry['speed_m_s'] > 7.0 or throttle >= 0.75:
                turn = max(turn, 0.25)
            elif telemetry['speed_m_s'] > 4.0 or throttle >= 0.5:
                turn = max(turn, 0.5)

            if Telemetry.is_turn_left(heading_d, degrees):
                turn = -turn
            self._driver.drive(throttle, turn)
            yield True

        self._logger.info('No waypoints, stopping')
        self._driver.drive(0.0, 0.0)
        self.stop()
        yield False

    def calibrate_compass(self, seconds):
        """Calibrates the compass."""
        # Don't calibrate while driving
        if self._run_course:
            self._logger.warn("Can't configure compass while running")
            return

        start = time.time()
        self._driver.drive(0.5, 1.0)
        try:
            while (self._run and not self._run_course
                   and time.time() < start + seconds):
                time.sleep(0.1)
        except:  # pylint: disable=bare-except
            pass
        self._driver.drive(0.0, 0.0)

    def set_max_throttle(self, throttle):
        """Sets the maximum throttle speed."""
        self._driver.set_max_throttle(throttle)

    def run_course(self):
        """Starts the RC car running the course."""
        self._run_course = True
        self._start_time = time.time()

    def stop(self):
        """Stops the RC car from running the course."""
        self._driver.drive(0.0, 0.0)
        self._run_course = False

    def reset(self):
        """Resets the waypoints for the RC car."""
        if self.is_running_course():
            self._logger.warn('Tried to reset the course while running')
            return
        self._waypoint_generator.reset()

    def kill(self):
        """Kills the thread."""
        self._run = False

    def is_running_course(self):
        """Returns True if we're currently navigating the course."""
        return self._run_course

    def _unstuck_yourself_iterator(self, seconds):
        """Commands the car to reverse and try to get off an obstacle."""
        # The ESC requires us to send neutral throttle for a bit, then send
        # reverse, then neutral, then reverse again (which will actually drive
        # the car in reverse)

        start = time.time()
        while time.time() < start + self.NEUTRAL_TIME_1_S:
            self._driver.drive(0.0, 0.0)
            yield True

        start = time.time()
        while time.time() < start + self.REVERSE_TIME_S:
            self._driver.drive(-0.5, 0.0)
            yield True

        start = time.time()
        while time.time() < start + self.NEUTRAL_TIME_2_S:
            self._driver.drive(0.0, 0.0)
            yield True

        telemetry = self._telemetry.get_data()
        heading_d = telemetry['heading_d']
        current_waypoint = self._waypoint_generator.get_current_waypoint(
            telemetry['x_m'], telemetry['y_m'])
        degrees = Telemetry.relative_degrees(telemetry['x_m'],
                                             telemetry['y_m'],
                                             current_waypoint[0],
                                             current_waypoint[1])
        is_left = Telemetry.is_turn_left(heading_d, degrees)
        if is_left is None:
            turn_direction = 1.0 if random.randint(0, 1) == 0 else -1.0
        elif is_left:
            turn_direction = 1.0  # Reversed because we're driving in reverse
        else:
            turn_direction = -1.0

        start = time.time()
        while time.time() < start + seconds:
            self._driver.drive(-.5, turn_direction)
            yield True

        # Pause for a bit; jamming from reverse to drive is a bad idea
        start = time.time()
        while time.time() < start + self.NEUTRAL_TIME_3_S:
            self._driver.drive(0.0, 0.0)
            yield True

        yield False

    def _stop_recording(self):
        """Stops recording after a little while."""
        time.sleep(5)
        self._camera.stop_recording()
Exemple #9
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
Exemple #10
0
class Driver(object):
    """Class that implements the Driver interface."""

    def __init__(self, telemetry):
        self._telemetry = telemetry
        self._logger = AsyncLogger()
        self._throttle = 0.0
        self._steering = 0.0
        self._max_throttle = 1.0

        with open('/dev/pi-blaster', 'w') as blaster:
            blaster.write(
                '{pin}={throttle}\n'.format(
                    pin=THROTTLE_GPIO_PIN,
                    throttle=self._get_throttle(0.0)
                )
            )
            blaster.write(
                '{pin}={steering}\n'.format(
                    pin=STEERING_GPIO_PIN,
                    steering=self._get_steering(0.0)
                )
            )

    def drive(self, throttle_percentage, steering_percentage):
        """Sends a command to the RC car. Throttle should be a float between
        -1.0 for reverse and 1.0 for forward. Turn should be a float between
        -1.0 for left and 1.0 for right.
        """
        assert -1.0 <= throttle_percentage <= 1.0
        assert -1.0 <= steering_percentage <= 1.0
        self._telemetry.process_drive_command(
            throttle_percentage,
            steering_percentage
        )
        self._logger.debug(
           'throttle = {throttle}, turn = {turn}'.format(
               throttle=throttle_percentage,
               turn=steering_percentage,
            )
        )
        self._throttle = throttle_percentage
        self._steering = steering_percentage

        self._logger.debug(
            'Throttle: {}, steering: {}'.format(
                throttle_percentage,
                steering_percentage
            )
        )

        if throttle_percentage > 0.0:
            throttle = min(self._max_throttle, throttle_percentage)
        else:
            # Reverse is slower than forward, so allow 2x
            throttle = max(-2 * self._max_throttle, throttle_percentage, -1.0)

        with open('/dev/pi-blaster', 'w') as blaster:
            blaster.write(
                '{pin}={throttle}\n'.format(
                    pin=THROTTLE_GPIO_PIN,
                    throttle=self._get_throttle(throttle)
                )
            )
            blaster.write(
                '{pin}={steering}\n'.format(
                    pin=STEERING_GPIO_PIN,
                    steering=self._get_steering(steering_percentage)
                )
            )

    def get_throttle(self):
        """Returns the current throttle."""
        return self._throttle

    def get_turn(self):
        """Returns the current turn."""
        return self._steering

    @staticmethod
    def _get_throttle(percentage):
        """Returns the throttle value."""
        # Purposely limit the reverse in case we try to go back while still
        # rolling - prevent damage to the gear box
        if not (-0.5 <= percentage <= 1.0):
            raise ValueError('Bad throttle: {}'.format(percentage))
        return round((THROTTLE_NEUTRAL_US + THROTTLE_DIFF * percentage) * 0.0001, 3)

    @staticmethod
    def _get_steering(percentage):
        """Returns the steering value."""
        if not (-1.0 <= percentage <= 1.0):
            raise ValueError('Bad steering')
        return round((STEERING_NEUTRAL_US + STEERING_DIFF * percentage) * 0.0001, 3)

    def set_max_throttle(self, max_throttle):
        """Sets the maximum throttle."""
        self._max_throttle = min(1.0, max_throttle)