screen-locker/screen_locker/_walk_tcx.py
2026-11-10 18:36:25 +01:00

173 lines
6.4 KiB
Python

"""One crediting rule for every GPS activity, from its RunnerUp-style TCX export.
A day's activities -- runs and walks, RunnerUp's files and the workout app's
alike -- are summed, and the day counts as the workout once EITHER
* their distance reaches ``MIN_RUN_DISTANCE_KM`` (within the GPS tolerance), OR
* their *moving* time reaches the shared duration bar
(``WORKOUT_DURATION_ACCEPT_MINUTES``).
The TCX sport and the ``_Walking`` file-name suffix never change the outcome;
they only decide the label a message and the log's ``activity`` field carry
(the phone's history shows it).
**Which files are walks (label only).** RunnerUp's WebDAV and File
synchronisers both export with ``ExportOptions.getDefault()``, whose TCX writer
knows only Running/Biking and writes ``Sport="Other"`` for a walk. The sport
survives only in the file name (``Sport.TapiriikType()``): ``..._Walking.tcx``.
The Strava export variant writes ``Sport="Walking"``, so that counts too.
**Moving time, not elapsed.** A lap's ``TotalTimeSeconds`` is wall-clock: one
real lap reads 101 min for 1 km of standing about. The track is cut into
windows of at least ``TRACK_WINDOW_SECONDS`` (a fix every second jitters, so
one raw pair can read 15 m/s inside a 3 m/s run) and each window is judged by
its average speed. Faster than ``MAX_MOVING_SPEED_MPS`` (25 km/h) is a vehicle
or a GPS jump: neither time nor distance counts, so a bus ride earns nothing.
Otherwise its metres count, and its seconds count as moving time at
``MIN_MOVING_SPEED_MPS`` or faster; standing still is too slow. A window longer
than ``WALK_MAX_GAP_SECONDS`` is a signal dropout (real exports have 2 minute
ones): the runner may have kept running unseen, so it counts as moving only at
``MIN_DROPOUT_SPEED_MPS`` or faster, while a pause (no distance across the
gap) earns nothing -- a pause cannot buy a run its duration.
The day's distance is the sum of the windows (``track_distance_m``), never the
lap's ``DistanceMeters`` total, and an export with no trackpoints cannot be
speed-checked, so it credits nothing. Only the root-DB fallback, which has no
trackpoints at all, is judged by its recorded distance and duration.
"""
from __future__ import annotations
from datetime import datetime
import logging
from typing import TYPE_CHECKING, Any
from screen_locker._activity_day import (
SPORT_NAMES,
ActivityDay,
activity_day,
activity_shortfall,
distance_qualifies,
moving_qualifies,
)
from screen_locker._constants import (
MAX_MOVING_SPEED_MPS,
MIN_DROPOUT_SPEED_MPS,
MIN_MOVING_SPEED_MPS,
TRACK_WINDOW_SECONDS,
WALK_MAX_GAP_SECONDS,
)
if TYPE_CHECKING:
from collections.abc import Iterator
import xml.etree.ElementTree as ET
_logger = logging.getLogger(__name__)
_TCX_NS = "{http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2}"
_WALKING = "Walking"
def is_walking_export(name: str, sport: str) -> bool:
"""Whether a TCX export is a walk, from its file name or its Sport tag."""
return name.endswith(f"_{_WALKING}.tcx") or sport == _WALKING
def _stamp(raw: str | None) -> float | None:
if not raw:
return None
try:
return datetime.fromisoformat(raw).timestamp()
except ValueError:
_logger.warning("TCX trackpoint has an unparsable time %r; skipped", raw)
return None
def _trackpoints(activity: ET.Element) -> list[tuple[float, float]]:
"""``(unix time, cumulative metres)`` of every usable trackpoint, in order."""
points: list[tuple[float, float]] = []
for point in activity.iter(f"{_TCX_NS}Trackpoint"):
when = _stamp(point.findtext(f"{_TCX_NS}Time"))
metres = point.findtext(f"{_TCX_NS}DistanceMeters")
if when is None or not metres:
continue
points.append((when, float(metres)))
points.sort()
return points
def _segments(
points: list[tuple[float, float]],
) -> Iterator[tuple[float, float]]:
"""``(seconds, metres)`` of the track cut into windows of >= TRACK_WINDOW_SECONDS.
A fix every second jitters (one pair can read 15 m/s inside a 3 m/s run),
so speed is judged over a window, never over a raw 1 s pair. Fixes that
are already further apart than the window are their own segment.
"""
if not points:
return
anchor = points[0]
for point in points[1:]:
if point[0] - anchor[0] >= TRACK_WINDOW_SECONDS:
yield point[0] - anchor[0], point[1] - anchor[1]
anchor = point
if points[-1] is not anchor:
yield points[-1][0] - anchor[0], points[-1][1] - anchor[1]
def track_summary(activity: ET.Element) -> dict[str, Any]:
"""Moving seconds, credited distance and end time, from the trackpoints.
A segment (see :func:`_segments`) counts unless it was travelled faster
than ``MAX_MOVING_SPEED_MPS``: it then adds its metres to
``track_distance_m``, and its seconds to ``moving_seconds`` too when it was
travelled at least at ``MIN_MOVING_SPEED_MPS``. A segment longer than
``WALK_MAX_GAP_SECONDS`` is a signal dropout whose motion is unknown; it
only counts as moving time at ``MIN_DROPOUT_SPEED_MPS``, so a pause (no
distance across the gap) earns no time while a runner whose GPS went away
for two minutes keeps the time and metres the far side proves.
"""
points = _trackpoints(activity)
moving = 0.0
metres = 0.0
for seconds, travelled in _segments(points):
if seconds <= 0:
continue
speed = travelled / seconds
if speed > MAX_MOVING_SPEED_MPS:
continue
metres += max(travelled, 0.0)
floor = (
MIN_MOVING_SPEED_MPS
if seconds <= WALK_MAX_GAP_SECONDS
else MIN_DROPOUT_SPEED_MPS
)
if speed >= floor:
moving += seconds
return {
"activity_id": activity.findtext(f"{_TCX_NS}Id") or "",
"moving_seconds": moving,
"track_distance_m": metres,
"ended_at": points[-1][0] if points else None,
}
# The day aggregate lives in ``_activity_day``; these names stay importable here,
# along with the walk-only names they replaced.
WalkDay = ActivityDay
walk_day = activity_day
walk_shortfall = activity_shortfall
__all__ = [
"SPORT_NAMES",
"ActivityDay",
"WalkDay",
"activity_day",
"activity_shortfall",
"distance_qualifies",
"is_walking_export",
"moving_qualifies",
"track_summary",
"walk_day",
"walk_shortfall",
]