diff options
-rw-r--r-- | CHANGELOG.rst | 1 | ||||
-rw-r--r-- | doc/user/transformers.rst | 15 | ||||
-rw-r--r-- | fietsboek/transformers/__init__.py | 13 | ||||
-rw-r--r-- | fietsboek/transformers/breaks.py | 123 |
4 files changed, 146 insertions, 6 deletions
diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b1e573b..4ce963a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,7 @@ Added - Profile pages with "milestone tracks" (longest, shortest, ...). - Integration with ``hittekaart`` for heatmaps on the profile. - A data version check at startup. +- The *Remove Breaks* transformer. Changed ^^^^^^^ diff --git a/doc/user/transformers.rst b/doc/user/transformers.rst index c8ddf01..d053052 100644 --- a/doc/user/transformers.rst +++ b/doc/user/transformers.rst @@ -43,3 +43,18 @@ point. To fix those points, the transformer will find the first correct point, and copy its elevation to the wrong points. + +Remove Breaks +------------- + +The *remove breaks* transformer removes longer breaks of inactivity by deleting +the points and shifting the following points back in time. This is useful e.g. +if you are waiting at a single spot for a while, and you would like that to be +removed for a cleaner log. + +Note that this transformer modifies the track's timings. Therefore, the +recording will end earlier than it did in reality, and the stopped time will be +reduced. + +A break is removed if the speed is below 1 kilometer per hour (approx. 0.28 +meters per second) for more than 5 minutes. diff --git a/fietsboek/transformers/__init__.py b/fietsboek/transformers/__init__.py index f6318d7..330e699 100644 --- a/fietsboek/transformers/__init__.py +++ b/fietsboek/transformers/__init__.py @@ -157,14 +157,11 @@ class FixNullElevation(Transformer): def execute(self, gpx: GPX): def all_points(): - return ( - point - for track in gpx.tracks - for segment in track.segments - for point in segment.points - ) + return gpx.walk(only_points=True) def rev_points(): + # We cannot use reversed(gpx.walk(...)) since that is not a + # generator, so we do it manually. return ( point for track in reversed(gpx.tracks) @@ -229,8 +226,12 @@ def list_transformers() -> list[type[Transformer]]: :return: A list of transformers. """ + # pylint: disable=import-outside-toplevel,cyclic-import + from .breaks import RemoveBreaks + return [ FixNullElevation, + RemoveBreaks, ] diff --git a/fietsboek/transformers/breaks.py b/fietsboek/transformers/breaks.py new file mode 100644 index 0000000..1c56414 --- /dev/null +++ b/fietsboek/transformers/breaks.py @@ -0,0 +1,123 @@ +"""Transformers that deal with breaks in the track.""" +import datetime + +from gpxpy.gpx import GPX, GPXTrack +from pyramid.i18n import TranslationString + +from . import Parameters, Transformer + +_ = TranslationString + +# 1km/h is the default limit of gpxpy. We convert this into m/s to make our +# live easier. +STOPPED_SPEED_LIMIT: float = 1 * 1000 / (60 * 60) +"""Speed limit (in m/s) at which the track is considered to be stopped.""" + +# We don't want to remove "breaks" at traffic lights or similar, so 5 minutes +# should be a good default. +MIN_BREAK_TO_REMOVE: datetime.timedelta = datetime.timedelta(minutes=5) +"""Minimum length of the break to trigger the removal.""" + + +class RemoveBreaks(Transformer): + """A transformer that fixes points with zero elevation.""" + + @classmethod + def identifier(cls) -> str: + return "remove-breaks" + + @classmethod + def name(cls) -> TranslationString: + return _("transformers.remove-breaks.title") + + @classmethod + def description(cls) -> TranslationString: + return _("transformers.remove-breaks.description") + + @classmethod + def parameter_model(cls) -> type[Parameters]: + return Parameters + + @property + def parameters(self) -> Parameters: + return Parameters() + + @parameters.setter + def parameters(self, value): + pass + + def execute(self, gpx: GPX): + for track in gpx.tracks: + self._clean(track) + + def _clean(self, track: GPXTrack): + if not track.get_points_no(): + return + + i = 0 + while i < track.get_points_no(): + segment_idx, point_idx = index(track, i) + point = track.segments[segment_idx].points[point_idx] + + # We check if the following points constitute a break, and if yes, + # how many of them + count = 0 + current_length = 0.0 + last_point = point + while True: + try: + j_segment, j_point = index(track, i + count + 1) + except IndexError: + break + current_point = track.segments[j_segment].points[j_point] + current_length += last_point.distance_3d(current_point) or 0.0 + last_point = current_point + + delta_t = datetime.timedelta(seconds=point.time_difference(last_point) or 0.0) + if not delta_t or current_length / delta_t.total_seconds() > STOPPED_SPEED_LIMIT: + break + count += 1 + + # No break, carry on! + if not count: + i += 1 + continue + + # At this point, check if the break is long enough to be removed + delta_t = datetime.timedelta(seconds=point.time_difference(last_point) or 0.0) + if delta_t < MIN_BREAK_TO_REMOVE: + i += 1 + continue + + # Here, we have a proper break to remove + # Delete the points belonging to the break ... + for _ in range(count): + j_segment, j_point = index(track, i + 1) + del track.segments[j_segment].points[j_point] + + # ... and shift the time of the following points + j = i + 1 + while j < track.get_points_no(): + j_segment, j_point = index(track, j) + track.segments[j_segment].points[j_point].adjust_time(-delta_t) + j += 1 + + +def index(track: GPXTrack, idx: int) -> tuple[int, int]: + """Takes a one-dimensional index (the point index) and returns an index + into the segment/segment points. + + :raises IndexError: When the given index is out of bounds. + :param track: The track for which to get the index. + :param idx: The "1D" index. + :return: A tuple with the segment index, and the index of the point within + the segment. + """ + for segment_idx, segment in enumerate(track.segments): + if idx < len(segment.points): + return (segment_idx, idx) + idx -= len(segment.points) + raise IndexError + + +__all__ = ["RemoveBreaks"] |