Skip to content

Commit 7e89d8c

Browse files
committed
Fix Garmin metric recovery for reruns
1 parent 9216784 commit 7e89d8c

3 files changed

Lines changed: 166 additions & 24 deletions

File tree

chronicle/activity_pipeline.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2325,8 +2325,20 @@ def _get_garmin_client(settings: Settings) -> Any | None:
23252325
try:
23262326
from garminconnect import Garmin
23272327

2328+
tokenstore_dir = settings.state_dir / "garmin_tokens"
23282329
client = Garmin(settings.garmin_email, settings.garmin_password)
2330+
if tokenstore_dir.exists():
2331+
try:
2332+
client.login(str(tokenstore_dir))
2333+
return client
2334+
except Exception as exc:
2335+
logger.warning("Garmin token login failed, retrying with credentials: %s", exc)
2336+
23292337
client.login()
2338+
try:
2339+
client.garth.dump(str(tokenstore_dir))
2340+
except Exception as exc:
2341+
logger.debug("Failed to persist Garmin session tokens: %s", exc)
23302342
return client
23312343
except Exception as exc:
23322344
logger.error("Garmin login failed: %s", exc)

chronicle/stat_modules/garmin_metrics.py

Lines changed: 96 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,96 @@ def safe_get(value: Any, keys: list[Any], default: Any = "N/A") -> Any:
6262
return cursor
6363

6464

65+
def _looks_like_training_status_record(value: Any) -> bool:
66+
if not isinstance(value, dict):
67+
return False
68+
return any(
69+
key in value
70+
for key in (
71+
"trainingStatusFeedbackPhrase",
72+
"acuteTrainingLoadDTO",
73+
"weeklyTrainingLoad",
74+
"fitnessTrend",
75+
"loadLevelTrend",
76+
)
77+
)
78+
79+
80+
def _select_training_status_record(
81+
training_status: dict[str, Any],
82+
*,
83+
last_activity: dict[str, Any] | None = None,
84+
) -> dict[str, Any]:
85+
latest_training_status_data = safe_get(
86+
training_status,
87+
["mostRecentTrainingStatus", "latestTrainingStatusData"],
88+
default={},
89+
)
90+
if _looks_like_training_status_record(latest_training_status_data):
91+
return latest_training_status_data
92+
if not isinstance(latest_training_status_data, dict):
93+
return {}
94+
95+
device_id = None
96+
if isinstance(last_activity, dict):
97+
device_id = last_activity.get("deviceId")
98+
if device_id not in (None, ""):
99+
by_device = latest_training_status_data.get(str(device_id))
100+
if _looks_like_training_status_record(by_device):
101+
return by_device
102+
103+
for candidate in latest_training_status_data.values():
104+
if _looks_like_training_status_record(candidate):
105+
return candidate
106+
return {}
107+
108+
109+
def _looks_like_training_readiness_entry(value: Any) -> bool:
110+
if not isinstance(value, dict):
111+
return False
112+
return any(
113+
key in value
114+
for key in (
115+
"level",
116+
"score",
117+
"sleepScore",
118+
"feedbackShort",
119+
"feedbackLong",
120+
"recoveryTime",
121+
)
122+
)
123+
124+
125+
def _select_training_readiness_entry(payload: Any) -> dict[str, Any]:
126+
if _looks_like_training_readiness_entry(payload):
127+
return payload
128+
if isinstance(payload, list):
129+
for item in payload:
130+
if _looks_like_training_readiness_entry(item):
131+
return item
132+
return {}
133+
if not isinstance(payload, dict):
134+
return {}
135+
136+
for key in ("dailyReadiness", "trainingReadiness", "readiness", "item", "result"):
137+
nested = payload.get(key)
138+
if _looks_like_training_readiness_entry(nested):
139+
return nested
140+
if isinstance(nested, list):
141+
for item in nested:
142+
if _looks_like_training_readiness_entry(item):
143+
return item
144+
145+
for nested in payload.values():
146+
if _looks_like_training_readiness_entry(nested):
147+
return nested
148+
if isinstance(nested, list):
149+
for item in nested:
150+
if _looks_like_training_readiness_entry(item):
151+
return item
152+
return {}
153+
154+
65155
def _default_metrics() -> dict[str, Any]:
66156
return {
67157
"vo2max": "N/A",
@@ -976,35 +1066,17 @@ def fetch_training_status_and_scores(client: Any) -> dict[str, Any]:
9761066
logger.error("Failed to fetch Garmin training status: %s", exc)
9771067
training_status = {}
9781068

979-
latest_status_data = safe_get(
980-
training_status,
981-
["mostRecentTrainingStatus", "latestTrainingStatusData", "3417115846"],
982-
default={},
983-
)
984-
if not isinstance(latest_status_data, dict):
985-
latest_status_data = {}
1069+
latest_status_data = _select_training_status_record(training_status, last_activity=last_activity)
9861070
acute_load_dto = latest_status_data.get("acuteTrainingLoadDTO")
9871071
if not isinstance(acute_load_dto, dict):
9881072
acute_load_dto = {}
9891073

990-
feedback = safe_get(
991-
training_status,
992-
["mostRecentTrainingStatus", "latestTrainingStatusData", "3417115846", "trainingStatusFeedbackPhrase"],
993-
)
994-
acwr_status_raw = safe_get(
995-
training_status,
996-
["mostRecentTrainingStatus", "latestTrainingStatusData", "3417115846", "acuteTrainingLoadDTO", "acwrStatus"],
997-
)
1074+
feedback = latest_status_data.get("trainingStatusFeedbackPhrase", "N/A")
1075+
acwr_status_raw = safe_get(latest_status_data, ["acuteTrainingLoadDTO", "acwrStatus"])
9981076

9991077
metrics["vo2max"] = safe_get(training_status, ["mostRecentVO2Max", "generic", "vo2MaxPreciseValue"])
1000-
metrics["chronic_load"] = safe_get(
1001-
training_status,
1002-
["mostRecentTrainingStatus", "latestTrainingStatusData", "3417115846", "acuteTrainingLoadDTO", "dailyTrainingLoadChronic"],
1003-
)
1004-
metrics["acute_load"] = safe_get(
1005-
training_status,
1006-
["mostRecentTrainingStatus", "latestTrainingStatusData", "3417115846", "acuteTrainingLoadDTO", "dailyTrainingLoadAcute"],
1007-
)
1078+
metrics["chronic_load"] = safe_get(latest_status_data, ["acuteTrainingLoadDTO", "dailyTrainingLoadChronic"])
1079+
metrics["acute_load"] = safe_get(latest_status_data, ["acuteTrainingLoadDTO", "dailyTrainingLoadAcute"])
10081080
metrics["load_tunnel_min"] = latest_status_data.get("loadTunnelMin", "N/A")
10091081
metrics["load_tunnel_max"] = latest_status_data.get("loadTunnelMax", "N/A")
10101082
metrics["weekly_training_load"] = latest_status_data.get("weeklyTrainingLoad", "N/A")
@@ -1063,7 +1135,7 @@ def fetch_training_status_and_scores(client: Any) -> dict[str, Any]:
10631135

10641136
try:
10651137
readiness = client.get_training_readiness(start_date)
1066-
readiness_entry = readiness[0] if isinstance(readiness, list) and readiness else {}
1138+
readiness_entry = _select_training_readiness_entry(readiness)
10671139
readiness_level = safe_get(readiness_entry, ["level"], default="N/A")
10681140
metrics["training_readiness_score"] = safe_get(readiness_entry, ["score"], default="N/A")
10691141
metrics["sleep_score"] = safe_get(readiness_entry, ["sleepScore"], default="N/A")

tests/test_garmin_metrics.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,54 @@ def get_activity_exercise_sets(self, _activity_id):
275275
}
276276

277277

278+
class _DynamicGarminClient(_DummyGarminClient):
279+
def get_last_activity(self):
280+
payload = dict(super().get_last_activity())
281+
payload["deviceId"] = 999999999
282+
return payload
283+
284+
def get_training_status(self, _end_date):
285+
return {
286+
"mostRecentVO2Max": {"generic": {"vo2MaxPreciseValue": 57.2}},
287+
"mostRecentTrainingStatus": {
288+
"latestTrainingStatusData": {
289+
"999999999": {
290+
"trainingStatusFeedbackPhrase": "PRODUCTIVE_BUILDING",
291+
"fitnessTrend": "IMPROVING",
292+
"loadLevelTrend": "WITHIN_RANGE",
293+
"weeklyTrainingLoad": 568,
294+
"loadTunnelMin": 60,
295+
"loadTunnelMax": 92,
296+
"acuteTrainingLoadDTO": {
297+
"acwrStatus": "OPTIMAL",
298+
"dailyTrainingLoadChronic": 72,
299+
"dailyTrainingLoadAcute": 78,
300+
"dailyAcuteChronicWorkloadRatio": 1.12,
301+
"acwrPercent": 112,
302+
},
303+
}
304+
}
305+
},
306+
}
307+
308+
def get_training_readiness(self, _start_date):
309+
return {
310+
"dailyReadiness": {
311+
"level": "HIGH",
312+
"score": 83,
313+
"sleepScore": 86,
314+
"feedbackShort": "Recovering well",
315+
"recoveryTime": 34200,
316+
"sleepScoreFactorPercent": 82,
317+
"sleepHistoryFactorPercent": 77,
318+
"hrvFactorPercent": 69,
319+
"stressHistoryFactorPercent": 74,
320+
"acwrFactorPercent": 88,
321+
"recoveryTimeFactorPercent": 71,
322+
}
323+
}
324+
325+
278326
class TestVo2MaxExpandedMetrics(unittest.TestCase):
279327
def test_fetch_training_status_returns_expanded_fields(self) -> None:
280328
metrics = fetch_training_status_and_scores(_DummyGarminClient())
@@ -313,6 +361,16 @@ def test_fetch_training_status_returns_expanded_fields(self) -> None:
313361
self.assertIsInstance(details, dict)
314362
self.assertEqual(details["chronological_age"], 40)
315363

364+
def test_fetch_training_status_handles_dynamic_device_id_and_dict_readiness(self) -> None:
365+
metrics = fetch_training_status_and_scores(_DynamicGarminClient())
366+
367+
self.assertEqual(metrics["training_status_key"], "Productive")
368+
self.assertEqual(metrics["readiness_level"], "HIGH")
369+
self.assertEqual(metrics["training_readiness_score"], 83)
370+
self.assertEqual(metrics["sleep_score"], 86)
371+
self.assertEqual(metrics["weekly_training_load"], 568)
372+
self.assertEqual(metrics["daily_acwr_ratio"], 1.12)
373+
316374
def test_get_activity_context_for_strava_activity_matches_strength_session(self) -> None:
317375
strava_activity = {
318376
"id": 17455368360,

0 commit comments

Comments
 (0)