mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
146 lines
5.2 KiB
Python
146 lines
5.2 KiB
Python
"""Walking in RunnerUp TCX exports: which files are walks, and their moving time.
|
|
|
|
A day's walking counts as the workout once its walks -- usually separate
|
|
commute legs, one file each -- add up to the shared workout duration bar
|
|
(``WORKOUT_DURATION_ACCEPT_MINUTES``) of *moving* time.
|
|
|
|
**Which files are walks.** 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.
|
|
``Other`` alone is not a walk: it is every sport RunnerUp cannot name.
|
|
|
|
**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 whose speed is walking speed
|
|
(``WALK_SPEED_MPS``): standing still is too slow, a tram or a bus too fast.
|
|
A pair further apart than ``WALK_MAX_GAP_SECONDS`` is a GPS dropout whose
|
|
motion is unknown and earns nothing -- the walk is never credited for time
|
|
nobody saw.
|
|
"""
|
|
|
|
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_WORKOUT_DURATION_MINUTES,
|
|
WALK_MAX_GAP_SECONDS,
|
|
WALK_SPEED_MPS,
|
|
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"
|
|
|
|
|
|
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)
|
|
slow, fast = WALK_SPEED_MPS
|
|
moving = 0.0
|
|
for (t0, d0), (t1, d1) in itertools.pairwise(points):
|
|
gap = t1 - t0
|
|
if 0 < gap <= WALK_MAX_GAP_SECONDS and slow <= (d1 - d0) / gap <= fast:
|
|
moving += gap
|
|
return {
|
|
"activity_id": activity.findtext(f"{_TCX_NS}Id") or "",
|
|
"moving_seconds": moving,
|
|
"ended_at": points[-1][0] if points else None,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WalkDay:
|
|
"""A day's walks, summed.
|
|
|
|
Attributes:
|
|
legs: Distinct walks (deduplicated by the activity's ``Id``: the
|
|
WebDAV copy and the phone's copy are the same walk).
|
|
moving_minutes: Their summed moving time.
|
|
distance_km: Their summed distance.
|
|
completed_at: Unix time the walk that crossed the bar ended -- when
|
|
the workout was actually done -- or ``None`` if it never did.
|
|
"""
|
|
|
|
legs: int
|
|
moving_minutes: float
|
|
distance_km: float
|
|
completed_at: float | None
|
|
|
|
@property
|
|
def qualifies(self) -> bool:
|
|
"""Whether the day's walking counts as its workout."""
|
|
return self.moving_minutes >= WORKOUT_DURATION_ACCEPT_MINUTES
|
|
|
|
def message(self) -> str:
|
|
"""The verification line, in the RunnerUp verifier's own style."""
|
|
return (
|
|
f"Walking: {self.legs} walk(s), {self.distance_km:.1f} km, "
|
|
f"{self.moving_minutes:.0f} min moving"
|
|
)
|
|
|
|
|
|
def walk_shortfall(day: WalkDay) -> str:
|
|
"""Why a day's walking did not count yet."""
|
|
return f"{day.message()} -- need {MIN_WORKOUT_DURATION_MINUTES}+ min moving"
|
|
|
|
|
|
def walk_day(walks: Iterable[dict[str, Any]]) -> WalkDay:
|
|
"""Sum parsed walk exports (``_parse_tcx`` dicts) into one :class:`WalkDay`."""
|
|
unique: dict[str, dict[str, Any]] = {}
|
|
for walk in walks:
|
|
unique.setdefault(str(walk.get("activity_id") or id(walk)), walk)
|
|
ordered = sorted(unique.values(), key=lambda w: w.get("ended_at") or 0.0)
|
|
moving = 0.0
|
|
completed_at: float | None = None
|
|
for walk in ordered:
|
|
moving += float(walk.get("moving_seconds", 0.0))
|
|
if completed_at is None and moving / 60 >= WORKOUT_DURATION_ACCEPT_MINUTES:
|
|
completed_at = walk.get("ended_at")
|
|
return WalkDay(
|
|
legs=len(ordered),
|
|
moving_minutes=moving / 60,
|
|
distance_km=sum(float(w.get("distance_m", 0.0)) for w in ordered) / 1000,
|
|
completed_at=completed_at,
|
|
)
|