diet-guard/diet_guard/_activity_kcal.py
Krzysztof kuhy Rudnicki e761926df4
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Body tab: weight/body-fat logs, BMI, ideal weight, goal-based kcal target, workouts + Health Connect steps, week planner
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 09:03:03 +02:00

223 lines
7.9 KiB
Python

"""Exercise energy per workout, by every formula the workout data supports.
Pure arithmetic over :class:`Session` values; where sessions come from is
:mod:`diet_guard._activity_sources`. Mirrored in
``app/lib/services/activity_kcal.dart`` and gated by the shared
``tests/fixtures/body_calc.json``.
Every formula returns **net** kcal -- energy above resting. The measured
TDEE adds exercise on top of ``BMR x 1.2``, which already pays for the resting
hour, so a gross figure would count it twice.
* **MET** (Ainsworth 2011 Compendium): ``(MET - 1) x kg x hours``. Running and
walking read MET off the Compendium's speed table (linear between rows);
lifting is code 02052 (resistance training, squats) = 5.0 -- between
02054 (multiple exercises, 8-15 reps, 3.5) and 02050 (power lifting,
vigorous, 6.0), since a 5x5 session is mostly rest between heavy sets.
* **ACSM** metabolic equations, level ground: VO2 = 0.2 v + 3.5 (running) or
0.1 v + 3.5 (walking), v in m/min; net kcal = (VO2 - 3.5) x kg x min x 5/1000.
* **Per-km**: running ~0.9 kcal/kg/km net (the classic "1 kcal/kg/km" is
gross), walking ~0.5 kcal/kg/km net.
ACSM and per-km need a distance, so they only exist for runs and walks that
carry one; every other session falls back to its MET figure (``fallback``) so
all three daily totals cover the same workouts.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
import itertools
__all__ = [
"FORMULAS",
"METHODS_NOTE",
"WINDOW_DAYS",
"Session",
"SessionKcal",
"daily_exercise",
"met_for",
"session_kcal",
"window_days",
]
FORMULAS: tuple[str, ...] = ("MET", "ACSM", "Per-km")
# Shown under the Activity panel on both surfaces.
METHODS_NOTE = (
"MET = standard activity values x your weight x time. "
"ACSM = American College of Sports Medicine running/walking equation from "
"your speed. Per-km = ~0.9 kcal per kg per km running, ~0.5 walking. "
"All are energy above resting."
)
# Measured activity averages the last two full weeks.
WINDOW_DAYS = 14
_RUN_MET_BY_KMH: tuple[tuple[float, float], ...] = (
(6.4, 6.0),
(8.0, 8.3),
(8.4, 9.0),
(9.7, 9.8),
(10.8, 10.5),
(11.3, 11.0),
(12.1, 11.5),
(12.9, 11.8),
(13.8, 12.3),
(14.5, 12.8),
(16.1, 14.5),
(17.7, 16.0),
(19.3, 19.0),
(20.9, 19.8),
(22.5, 23.0),
)
_WALK_MET_BY_KMH: tuple[tuple[float, float], ...] = (
(3.2, 2.8),
(4.0, 3.0),
(4.8, 3.5),
(5.6, 4.3),
(6.4, 5.0),
(7.2, 7.0),
(8.0, 8.3),
)
# Compendium codes: 02052 resistance training, 12150 running general,
# 17200 walking general-ish, 01015 bicycling general, generic moderate.
_FIXED_MET = {"strength": 5.0, "run": 8.0, "walk": 3.5, "cycle": 7.5, "other": 4.0}
_ACSM_SLOPE = {"run": 0.2, "walk": 0.1}
_NET_KCAL_PER_KG_KM = {"run": 0.9, "walk": 0.5}
_KCAL_PER_LITRE_O2 = 5.0
@dataclass(frozen=True)
class Session:
"""One workout, reduced to what the energy formulas need.
Attributes:
day: Local ``YYYY-MM-DD`` the workout happened on.
kind: ``strength``, ``run``, ``walk``, ``cycle`` or ``other``.
minutes: Duration.
km: Distance, or None when the source has none.
label: Human description for the list.
source: Where it came from (``runnerup``, ``screen-locker``).
start: Local ISO start time, when the source knows it.
end: Local ISO end time, when the source knows it. The phone drops
Health Connect steps inside ``start..end`` so a walk is not
counted as both a workout and steps.
"""
day: str
kind: str
minutes: float
km: float | None
label: str
source: str
start: str | None = None
end: str | None = None
@dataclass(frozen=True)
class SessionKcal:
"""Net kcal of one session per formula; ``fallback`` names MET stand-ins."""
session: Session
kcal: dict[str, float]
fallback: frozenset[str]
def _interpolate(table: tuple[tuple[float, float], ...], kmh: float) -> float:
"""Linear interpolation of MET at ``kmh``, clamped to the table's ends."""
if kmh <= table[0][0]:
return table[0][1]
for (x0, y0), (x1, y1) in itertools.pairwise(table):
if kmh <= x1:
return y0 + (y1 - y0) * (kmh - x0) / (x1 - x0)
return table[-1][1]
def _speed_kmh(session: Session) -> float | None:
"""Average speed, or None without a usable distance and duration."""
if session.km is None or session.km <= 0 or session.minutes <= 0:
return None
return session.km / (session.minutes / 60.0)
def met_for(session: Session) -> float:
"""The MET value for ``session``: speed-based when possible, else fixed."""
kmh = _speed_kmh(session)
if kmh is not None and session.kind == "run":
return _interpolate(_RUN_MET_BY_KMH, kmh)
if kmh is not None and session.kind == "walk":
return _interpolate(_WALK_MET_BY_KMH, kmh)
return _FIXED_MET.get(session.kind, _FIXED_MET["other"])
def session_kcal(session: Session, weight_kg: float) -> SessionKcal:
"""Net kcal of ``session`` for a ``weight_kg`` person, by every formula."""
hours = session.minutes / 60.0
met = (met_for(session) - 1.0) * weight_kg * hours
kcal = {"MET": met, "ACSM": met, "Per-km": met}
fallback = {"ACSM", "Per-km"}
kmh = _speed_kmh(session)
if kmh is not None and session.km is not None and session.kind in _ACSM_SLOPE:
metres_per_min = kmh * 1000.0 / 60.0
net_vo2 = _ACSM_SLOPE[session.kind] * metres_per_min
kcal["ACSM"] = net_vo2 * weight_kg * session.minutes * _KCAL_PER_LITRE_O2 / 1000
kcal["Per-km"] = _NET_KCAL_PER_KG_KM[session.kind] * weight_kg * session.km
fallback.clear()
return SessionKcal(session=session, kcal=kcal, fallback=frozenset(fallback))
def daily_exercise(
sessions: tuple[Session, ...],
weight_kg: float,
today: date,
extra_by_day: dict[str, float] | None = None,
) -> dict[str, float]:
"""Mean net exercise kcal per day over the last :data:`WINDOW_DAYS`.
Only full past days count: today is still in progress, so the window
ends yesterday. It starts :data:`WINDOW_DAYS` days back -- or, while the
data is younger than that, on the first day that has any, so a week of
history is averaged over that week rather than diluted over 14 days. The
denominator is calendar days within that span: a rest day burns no
exercise kcal, which is exactly what a daily average must reflect. With
no past data at all, every formula reads 0.
``extra_by_day`` (day -> net kcal, e.g. steps outside workouts) is added
to every formula alike: it is not a workout any formula could measure.
"""
extra = extra_by_day or {}
span = window_days(sessions, today, extra)
totals = dict.fromkeys(FORMULAS, 0.0)
if not span:
return totals
first = (today - timedelta(days=span)).isoformat()
last = today.isoformat()
for session in sessions:
if first <= session.day < last:
for name, value in session_kcal(session, weight_kg).kcal.items():
totals[name] += value
for day, kcal in extra.items():
if first <= day < last:
for name in totals:
totals[name] += kcal
return {name: total / span for name, total in totals.items()}
def window_days(
sessions: tuple[Session, ...],
today: date,
extra_by_day: dict[str, float] | None = None,
) -> int:
"""How many full past days :func:`daily_exercise` averages over.
:data:`WINDOW_DAYS`, or fewer while the earliest past data (a session or
an ``extra_by_day`` key before ``today``) is younger than that; 0 when
there is no past data at all.
"""
last = today.isoformat()
days = [s.day for s in sessions if s.day < last]
days += [d for d in (extra_by_day or {}) if d < last]
if not days:
return 0
return min((today - date.fromisoformat(min(days))).days, WINDOW_DAYS)