mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 17:53:56 +01:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
173 lines
6.4 KiB
Python
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",
|
|
]
|