示例#1
0
class Track(TimeStampedModel):
    class Meta:
        abstract = True

    name = models.CharField(max_length=100)
    description = models.TextField(blank=True)
    image = ThumbnailerImageField(upload_to=get_image_path,
                                  blank=True,
                                  null=True)

    # Main activity of the track
    activity_type = models.ForeignKey(ActivityType,
                                      default=1,
                                      on_delete=models.SET_DEFAULT)

    # link to athlete
    athlete = models.ForeignKey("Athlete",
                                on_delete=models.CASCADE,
                                related_name="tracks")

    # elevation gain in m
    total_elevation_gain = models.FloatField("Total elevation gain in m",
                                             default=0)

    # elevation loss in m
    total_elevation_loss = models.FloatField("Total elevation loss in m",
                                             default=0)

    # route distance in m
    total_distance = models.FloatField("Total length of the track in m",
                                       default=0)

    # geographic information
    geom = models.LineStringField("line geometry", srid=3857)

    # Start and End-place
    start_place = models.ForeignKey(Place,
                                    null=True,
                                    related_name="starts_%(class)s",
                                    on_delete=models.SET_NULL)

    end_place = models.ForeignKey(Place,
                                  null=True,
                                  related_name="ends_%(class)s",
                                  on_delete=models.SET_NULL)

    # uuid field to generate unique file names
    uuid = models.UUIDField(default=uuid4, editable=False)

    # track data as a pandas DataFrame
    data = DataFrameField(null=True,
                          upload_to=athlete_data_directory_path,
                          unique_fields=["uuid"])

    def calculate_step_distances(self, min_distance: float, commit=True):
        """
        calculate distance between each row, removing steps where distance is too small.
        """
        data = self.data.copy()
        data["geom"], srid = self.geom, self.geom.srid

        data.drop(data[data.distance.diff() < min_distance].index,
                  inplace=True)
        data["step_distance"] = data.distance.diff()

        try:
            self.geom = LineString(data.geom.tolist(), srid=srid)
        except ValueError:
            message = "Cannot clean track data: invalid distance values."
            logger.error(message, exc_info=True)
            raise ValueError(message)
        data.drop(columns=["geom"], inplace=True)
        self.data = data.fillna(value=0)

        if commit:
            self.save(update_fields=["data", "geom"])

    def calculate_gradients(self, max_gradient: float, commit=True):
        """
        calculate gradients in percents based on altitude and distance
        while cleaning up bad values.
        """
        data = self.data.copy()
        data["geom"], srid = self.geom, self.geom.srid

        # calculate gradients
        data["gradient"] = data.altitude.diff() / data.distance.diff() * 100

        # find rows with offending gradients
        bad_rows = data[(data["gradient"] < -max_gradient) |
                        (data["gradient"] > max_gradient)]

        # drop bad rows and recalculate until all offending values have been removed
        while not bad_rows.empty:
            data.drop(bad_rows.index, inplace=True)
            data["gradient"] = data.altitude.diff() / data.distance.diff(
            ) * 100
            bad_rows = data[(data["gradient"] < -max_gradient) |
                            (data["gradient"] > max_gradient)]

        # save the values back to the track object
        try:
            self.geom = LineString(data.geom.tolist(), srid=srid)
        except ValueError:
            message = "Cannot clean track data: invalid altitude values."
            logger.error(message, exc_info=True)
            raise ValueError(message)
        data.drop(columns=["geom"], inplace=True)
        self.data = data.fillna(value=0)

        if commit:
            self.save(update_fields=["data", "geom"])

    def calculate_cumulative_elevation_differences(self, commit=True):
        """
        Calculates two columns from the altitude data:
        - cumulative_elevation_gain: cumulative sum of positive elevation data
        - cumulative_elevation_loss: cumulative sum of negative elevation data
        """

        # only consider entries where altitude difference is greater than 0
        self.data["cumulative_elevation_gain"] = self.data.altitude.diff()[
            self.data.altitude.diff() >= 0].cumsum()

        # only consider entries where altitude difference is less than 0
        self.data["cumulative_elevation_loss"] = self.data.altitude.diff()[
            self.data.altitude.diff() <= 0].cumsum()

        # Fill the NaNs with the last valid value of the series
        # then, replace the remaining NaN (at the beginning) with 0
        self.data[["cumulative_elevation_gain",
                   "cumulative_elevation_loss"]] = (self.data[[
                       "cumulative_elevation_gain", "cumulative_elevation_loss"
                   ]].fillna(method="ffill").fillna(value=0))

        if commit:
            self.save(update_fields=["data"])

    def add_distance_and_elevation_totals(self, commit=True):
        """
        add total distance and total elevation gain to every row
        """
        self.data["total_distance"] = self.total_distance
        self.data["total_elevation_gain"] = self.total_elevation_gain

        if commit:
            self.save(update_fields=["data"])

    def update_permanent_track_data(self,
                                    min_step_distance=1,
                                    max_gradient=100,
                                    commit=True,
                                    force=False):
        """
        make sure all unvarying data columns required for
        schedule calculation are available.

        :param min_step_distance: minimum distance in m to keep between each point
        :param max_gradient: maximum gradient to keep when cleaning rows
        :param commit: save the instance to the database after update
        :param force: recalculates columns even if they are already present

        :returns: None
        :raises ValueError: if the number of coords in the track geometry
        is not equal to the number of rows in data or if the cleaned data columns
        are left with only one row.
        """
        # flag if any of the data columns have been updated
        track_data_updated = False

        # make sure we have step distances
        if "step_distance" not in self.data.columns or force:
            track_data_updated = True
            self.calculate_step_distances(min_distance=min_step_distance,
                                          commit=False)

        # make sure we have step gradients
        if "gradient" not in self.data.columns or force:
            track_data_updated = True
            self.calculate_gradients(max_gradient=max_gradient, commit=False)

        # make sure we have cumulative elevation differences
        if (not all(column in self.data.columns for column in
                    ["cumulative_elevation_gain", "cumulative_elevation_loss"])
                or force):
            track_data_updated = True
            self.calculate_cumulative_elevation_differences(commit=False)

        # make sure we have distance and elevation totals
        if (not all(column in self.data.columns
                    for column in ["total_distance", "total_elevation_gain"])
                or force):
            track_data_updated = True
            self.add_distance_and_elevation_totals(commit=False)

        # commit changes to the database if any
        if track_data_updated and commit:
            self.save(update_fields=["data", "geom"])

    def update_track_details_from_data(self, commit=True):
        """
        set track details from the track data,
        usually replacing remote information received for the route
        """
        if not all(column in
                   ["cumulative_elevation_gain", "cumulative_elevation_loss"]
                   for column in self.data.columns):
            self.calculate_cumulative_elevation_differences(commit=False)

        # update total_distance, total_elevation_gain and total_elevation_loss from data
        self.total_distance = self.data.distance.max()
        self.total_elevation_loss = abs(
            self.data.cumulative_elevation_loss.min())
        self.total_elevation_gain = self.data.cumulative_elevation_gain.max()

        if commit:
            self.save(update_fields=[
                "total_distance",
                "total_elevation_loss",
                "total_elevation_gain",
            ])

    def get_prediction_model(self, user):
        """
        get the prediction model from the Model instance containing prediction values

        Use an instance of ActivityPerformance if it exists for the athlete and
        activity type. Fallback on ActivityType otherwise.
        """
        if user.is_authenticated:
            try:
                performance = user.athlete.performances
                performance = performance.filter(
                    activity_type=self.activity_type).get()
                return performance.get_prediction_model()
            except ActivityPerformance.DoesNotExist:
                pass

        # no ActivityPerformance for the user, fallback on ActivityType
        return self.activity_type.get_prediction_model()

    def calculate_projected_time_schedule(self,
                                          user,
                                          workout_type=None,
                                          gear=None):
        """
        Calculates route pace and route schedule based on the athlete's prediction model
        for the route's activity type.
        """
        # make sure we have all required data columns
        self.update_permanent_track_data()

        # add temporary columns useful to the schedule calculation
        data = self.data

        # add gear and workout type to every row
        data["gear"] = gear or "None"
        data["workout_type"] = workout_type or "None"

        # restore prediction model for athlete and activity_type
        prediction_model = self.get_prediction_model(user)

        # keep model pipelines and columns in local variable for readability
        pipeline = prediction_model.pipeline
        numerical_columns = prediction_model.numerical_columns
        categorical_columns = prediction_model.categorical_columns
        feature_columns = numerical_columns + categorical_columns

        # calculate pace and schedule columns for the route
        data["pace"] = pipeline.predict(data[feature_columns])
        data["schedule"] = (data.pace *
                            data.step_distance).cumsum().fillna(value=0)

        self.data = data

    def get_data(self, line_location, data_column):
        """
        interpolate the value of a given column in the DataFrame
        based on the line_location and the total_distance column.
        """

        # calculate the distance value to interpolate with
        # based on line location and the total length of the track.
        interp_x = line_location * self.total_distance

        # interpolate the value, see:
        # https://docs.scipy.org/doc/numpy/reference/generated/numpy.interp.html
        return interp(interp_x, self.data["distance"], self.data[data_column])

    def get_distance_data(self, line_location, data_column, absolute=False):
        """
        wrap around the get_data method
        to return a Distance object.
        """
        distance_data = self.get_data(line_location, data_column)

        # return distance object
        if distance_data is not None:
            return D(m=abs(distance_data)) if absolute else D(m=distance_data)

    def get_time_data(self, line_location, data_column):
        """
        wrap around the get_data method
        to return a timedelta object.
        """
        time_data = self.get_data(line_location, data_column)
        # return time object
        if time_data is not None:
            return timedelta(seconds=int(time_data))

    def get_start_altitude(self):
        start_altitude = self.get_distance_data(0, "altitude")
        return start_altitude

    def get_end_altitude(self):
        end_altitude = self.get_distance_data(1, "altitude")
        return end_altitude

    def get_total_distance(self):
        """
        returns track total_distance as a Distance object
        """
        return D(m=self.total_distance)

    def get_total_elevation_gain(self):
        """
        returns total altitude gain as a Distance object
        """
        return D(m=self.total_elevation_gain)

    def get_total_elevation_loss(self):
        """
        returns total altitude loss as a Distance object
        """
        return D(m=self.total_elevation_loss)

    def get_total_duration(self):
        """
        returns total duration as a timedelta object
        """
        return self.get_time_data(1, "schedule")

    def get_closest_places_along_line(self, line_location=0, max_distance=200):
        """
        retrieve Place objects with a given distance of a point on the line.
        """
        # create the point from location
        point = self.geom.interpolate_normalized(line_location)

        # get closest places to the point
        places = get_places_within(point, max_distance)

        return places

    def get_start_places(self, max_distance=200):
        """
        retrieve Place objects close to the start of the track.
        """
        return self.get_closest_places_along_line(line_location=0,
                                                  max_distance=max_distance)

    def get_end_places(self, max_distance=200):
        """
        retrieve Place objects close to the end of the track.
        """
        return self.get_closest_places_along_line(line_location=1,
                                                  max_distance=max_distance)