mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 18:13:56 +01:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
210 lines
7.6 KiB
Python
210 lines
7.6 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. Moving time is summed over
|
|
consecutive trackpoint pairs travelled at least at ``MIN_MOVING_SPEED_MPS``:
|
|
standing still is too slow. A pair further apart than ``WALK_MAX_GAP_SECONDS``
|
|
is a GPS dropout whose motion is unknown and earns nothing -- an activity is
|
|
never credited for time nobody saw, so a pause cannot buy a run its duration.
|
|
There is no upper speed: a run is faster than a walk and must still count.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
import itertools
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from screen_locker._constants import (
|
|
MIN_MOVING_SPEED_MPS,
|
|
MIN_RUN_DISTANCE_KM,
|
|
MIN_WORKOUT_DURATION_MINUTES,
|
|
RUNNERUP_DISTANCE_TOLERANCE,
|
|
WALK_MAX_GAP_SECONDS,
|
|
WORKOUT_DURATION_ACCEPT_MINUTES,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
import xml.etree.ElementTree as ET
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_TCX_NS = "{http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2}"
|
|
_WALKING = "Walking"
|
|
|
|
# RunnerUp's sport codes, for labels only (the label never decides credit).
|
|
SPORT_NAMES: dict[int, str] = {
|
|
0: "Running",
|
|
1: "Biking",
|
|
2: "Other",
|
|
3: "Orienteering",
|
|
4: "Walking",
|
|
5: "Treadmill",
|
|
6: "Gym",
|
|
7: "Stationary Bike",
|
|
}
|
|
|
|
|
|
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 track_summary(activity: ET.Element) -> dict[str, Any]:
|
|
"""Moving seconds and end time of one activity, from its trackpoints."""
|
|
points = _trackpoints(activity)
|
|
moving = 0.0
|
|
for (t0, d0), (t1, d1) in itertools.pairwise(points):
|
|
gap = t1 - t0
|
|
if 0 < gap <= WALK_MAX_GAP_SECONDS and (d1 - d0) / gap >= MIN_MOVING_SPEED_MPS:
|
|
moving += gap
|
|
return {
|
|
"activity_id": activity.findtext(f"{_TCX_NS}Id") or "",
|
|
"moving_seconds": moving,
|
|
"ended_at": points[-1][0] if points else None,
|
|
}
|
|
|
|
|
|
def distance_qualifies(distance_km: float) -> bool:
|
|
"""Whether a distance meets the minimum, with the GPS tolerance."""
|
|
return distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) >= MIN_RUN_DISTANCE_KM
|
|
|
|
|
|
def moving_qualifies(moving_minutes: float) -> bool:
|
|
"""Whether moving time meets the shared duration bar (leeway included)."""
|
|
return moving_minutes >= WORKOUT_DURATION_ACCEPT_MINUTES
|
|
|
|
|
|
def _moving_seconds(activity: dict[str, Any]) -> float:
|
|
"""Moving seconds; the root-DB path has no trackpoints, so wall-clock stands in."""
|
|
return float(activity.get("moving_seconds", activity.get("duration_seconds", 0.0)))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ActivityDay:
|
|
"""A day's activities, summed.
|
|
|
|
Attributes:
|
|
legs: Distinct activities (deduplicated by the activity's ``Id``: the
|
|
WebDAV copy and the phone's copy are the same activity).
|
|
moving_minutes: Their summed moving time.
|
|
distance_km: Their summed distance.
|
|
completed_at: Unix time the activity that took the day over the bar
|
|
ended -- when the workout was actually done -- or ``None`` if the
|
|
day never got there (or the activity recorded no end).
|
|
all_walks: Whether every activity was a walk (label only).
|
|
sport: The sport code when the day is a single non-walk activity.
|
|
"""
|
|
|
|
legs: int
|
|
moving_minutes: float
|
|
distance_km: float
|
|
completed_at: float | None
|
|
all_walks: bool = False
|
|
sport: int = -1
|
|
|
|
@property
|
|
def qualifies(self) -> bool:
|
|
"""Whether the day counts as its workout: distance OR moving time."""
|
|
return distance_qualifies(self.distance_km) or moving_qualifies(
|
|
self.moving_minutes
|
|
)
|
|
|
|
def message(self) -> str:
|
|
"""The verification line, in the RunnerUp verifier's own style."""
|
|
if self.all_walks:
|
|
return (
|
|
f"Walking: {self.legs} walk(s), {self.distance_km:.1f} km, "
|
|
f"{self.moving_minutes:.0f} min moving"
|
|
)
|
|
if self.legs == 1:
|
|
name = SPORT_NAMES.get(self.sport, str(self.sport))
|
|
return f"{name}: {self.distance_km:.1f} km in {self.moving_minutes:.0f} min"
|
|
return (
|
|
f"Activity: {self.legs} activities, {self.distance_km:.1f} km, "
|
|
f"{self.moving_minutes:.0f} min moving"
|
|
)
|
|
|
|
|
|
def activity_shortfall(day: ActivityDay) -> str:
|
|
"""Why a day's activity did not count yet."""
|
|
return (
|
|
f"{day.message()} -- need {MIN_RUN_DISTANCE_KM:g}+ km or "
|
|
f"{MIN_WORKOUT_DURATION_MINUTES}+ min moving"
|
|
)
|
|
|
|
|
|
def activity_day(activities: Iterable[dict[str, Any]]) -> ActivityDay:
|
|
"""Sum parsed exports (``parse_tcx_file`` dicts) into one :class:`ActivityDay`."""
|
|
unique: dict[str, dict[str, Any]] = {}
|
|
for activity in activities:
|
|
unique.setdefault(str(activity.get("activity_id") or id(activity)), activity)
|
|
ordered = sorted(unique.values(), key=lambda a: a.get("ended_at") or 0.0)
|
|
moving = 0.0
|
|
metres = 0.0
|
|
completed_at: float | None = None
|
|
for activity in ordered:
|
|
moving += _moving_seconds(activity)
|
|
metres += float(activity.get("distance_m", 0.0))
|
|
if completed_at is None and (
|
|
moving_qualifies(moving / 60) or distance_qualifies(metres / 1000)
|
|
):
|
|
completed_at = activity.get("ended_at")
|
|
return ActivityDay(
|
|
legs=len(ordered),
|
|
moving_minutes=moving / 60,
|
|
distance_km=metres / 1000,
|
|
completed_at=completed_at,
|
|
all_walks=bool(ordered) and all(a.get("walking") for a in ordered),
|
|
sport=int(ordered[0].get("sport", -1)) if ordered else -1,
|
|
)
|
|
|
|
|
|
# Back-compat names: the walk-only rule these replaced.
|
|
WalkDay = ActivityDay
|
|
walk_day = activity_day
|
|
walk_shortfall = activity_shortfall
|