Public Access
1253 lines
25 KiB
Python
1253 lines
25 KiB
Python
import json
|
||
import math
|
||
import os
|
||
from datetime import datetime
|
||
|
||
|
||
BASE_DIR = os.path.dirname(__file__)
|
||
|
||
SOURCE_FILE = os.path.join(
|
||
BASE_DIR,
|
||
"vyta-garmin.json",
|
||
)
|
||
|
||
# Docker/Unraid kan ange VYTA_OUTPUT_PATH=/data/vyta-data.json.
|
||
# Utan miljövariabel behålls den gamla lokala fallbacken.
|
||
OUTPUT_FILE = os.environ.get(
|
||
"VYTA_OUTPUT_PATH",
|
||
os.path.abspath(
|
||
os.path.join(
|
||
BASE_DIR,
|
||
"..",
|
||
"vyta",
|
||
"private-data",
|
||
"vyta-data.json",
|
||
)
|
||
),
|
||
)
|
||
|
||
OUTPUT_DIR = os.path.dirname(
|
||
OUTPUT_FILE
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# GENERELLA HJÄLPFUNKTIONER
|
||
# ---------------------------------------------------------
|
||
|
||
def safe_number(value):
|
||
if value is None:
|
||
return None
|
||
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def round_or_none(value, digits=0):
|
||
value = safe_number(value)
|
||
|
||
if value is None:
|
||
return None
|
||
|
||
return round(value, digits)
|
||
|
||
|
||
def extract_vo2max(value):
|
||
"""
|
||
Plocka ut Garmins VO2 max som ett enkelt numeriskt värde.
|
||
|
||
Garmin kan returnera antingen ett tal eller ett objekt med
|
||
generic.vo2MaxPreciseValue / generic.vo2MaxValue.
|
||
"""
|
||
numeric = safe_number(value)
|
||
|
||
if numeric is not None:
|
||
return numeric
|
||
|
||
if not isinstance(value, dict):
|
||
return None
|
||
|
||
generic = value.get(
|
||
"generic"
|
||
)
|
||
|
||
if isinstance(generic, dict):
|
||
precise = safe_number(
|
||
generic.get(
|
||
"vo2MaxPreciseValue"
|
||
)
|
||
)
|
||
|
||
if precise is not None:
|
||
return precise
|
||
|
||
regular = safe_number(
|
||
generic.get(
|
||
"vo2MaxValue"
|
||
)
|
||
)
|
||
|
||
if regular is not None:
|
||
return regular
|
||
|
||
for key in (
|
||
"vo2MaxPreciseValue",
|
||
"vo2MaxValue",
|
||
"vo2max",
|
||
"value",
|
||
):
|
||
candidate = safe_number(
|
||
value.get(key)
|
||
)
|
||
|
||
if candidate is not None:
|
||
return candidate
|
||
|
||
return None
|
||
|
||
|
||
def average(values):
|
||
valid = [
|
||
float(value)
|
||
for value in values
|
||
if value is not None
|
||
]
|
||
|
||
if not valid:
|
||
return None
|
||
|
||
return sum(valid) / len(valid)
|
||
|
||
|
||
def clamp(value, minimum, maximum):
|
||
return max(
|
||
minimum,
|
||
min(maximum, value)
|
||
)
|
||
|
||
|
||
def minutes(seconds):
|
||
value = safe_number(seconds)
|
||
|
||
if value is None:
|
||
return None
|
||
|
||
return round(value / 60)
|
||
|
||
|
||
def date_from_activity(activity):
|
||
start = activity.get("startTimeLocal")
|
||
|
||
if not start:
|
||
return None
|
||
|
||
return start[:10]
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# AVGÖR OM EN DAG HAR RIKTIG DATA
|
||
# ---------------------------------------------------------
|
||
|
||
def has_meaningful_data(day):
|
||
daily = day.get("daily", {})
|
||
sleep = day.get("sleep", {})
|
||
hrv = day.get("hrv", {})
|
||
stress = day.get("stress", {})
|
||
|
||
values = [
|
||
daily.get("restingHeartRate"),
|
||
daily.get("steps"),
|
||
daily.get("bodyBatteryCurrent"),
|
||
sleep.get("score"),
|
||
sleep.get("durationMinutes"),
|
||
hrv.get("nightAverage"),
|
||
stress.get("average"),
|
||
]
|
||
|
||
count = sum(
|
||
1
|
||
for value in values
|
||
if value is not None
|
||
)
|
||
|
||
return count >= 2
|
||
|
||
|
||
def has_recovery_data(day):
|
||
daily = day.get("daily", {})
|
||
sleep = day.get("sleep", {})
|
||
hrv = day.get("hrv", {})
|
||
|
||
return (
|
||
daily.get("restingHeartRate") is not None
|
||
and sleep.get("score") is not None
|
||
and hrv.get("nightAverage") is not None
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# BASELINES
|
||
# ---------------------------------------------------------
|
||
|
||
def calculate_baselines(days):
|
||
valid_days = [
|
||
day
|
||
for day in days
|
||
if has_meaningful_data(day)
|
||
]
|
||
|
||
# Max senaste 30 dagar som faktiskt innehåller data.
|
||
valid_days = valid_days[-30:]
|
||
|
||
return {
|
||
"hrv": round_or_none(
|
||
average([
|
||
day.get("hrv", {}).get("nightAverage")
|
||
for day in valid_days
|
||
]),
|
||
1,
|
||
),
|
||
|
||
"restingHeartRate": round_or_none(
|
||
average([
|
||
day.get("daily", {}).get(
|
||
"restingHeartRate"
|
||
)
|
||
for day in valid_days
|
||
]),
|
||
1,
|
||
),
|
||
|
||
"sleepScore": round_or_none(
|
||
average([
|
||
day.get("sleep", {}).get("score")
|
||
for day in valid_days
|
||
]),
|
||
1,
|
||
),
|
||
|
||
"stress": round_or_none(
|
||
average([
|
||
day.get("stress", {}).get("average")
|
||
or day.get("daily", {}).get(
|
||
"averageStress"
|
||
)
|
||
for day in valid_days
|
||
]),
|
||
1,
|
||
),
|
||
|
||
"bodyBattery": round_or_none(
|
||
average([
|
||
day.get("daily", {}).get(
|
||
"bodyBatteryCurrent"
|
||
)
|
||
for day in valid_days
|
||
]),
|
||
1,
|
||
),
|
||
|
||
"spo2": round_or_none(
|
||
average([
|
||
day.get("spo2", {}).get("average")
|
||
or day.get("daily", {}).get(
|
||
"averageSpo2"
|
||
)
|
||
for day in valid_days
|
||
]),
|
||
1,
|
||
),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# RECOVERY
|
||
#
|
||
# 0–100
|
||
#
|
||
# Vi använder relativa avvikelser mot personlig baseline.
|
||
# ---------------------------------------------------------
|
||
|
||
def calculate_recovery(day, baselines):
|
||
daily = day.get("daily", {})
|
||
sleep = day.get("sleep", {})
|
||
hrv = day.get("hrv", {})
|
||
stress = day.get("stress", {})
|
||
|
||
hrv_value = safe_number(
|
||
hrv.get("nightAverage")
|
||
)
|
||
|
||
rhr_value = safe_number(
|
||
daily.get("restingHeartRate")
|
||
)
|
||
|
||
sleep_score = safe_number(
|
||
sleep.get("score")
|
||
)
|
||
|
||
stress_value = safe_number(
|
||
stress.get("average")
|
||
or daily.get("averageStress")
|
||
)
|
||
|
||
body_battery = safe_number(
|
||
daily.get("bodyBatteryCurrent")
|
||
)
|
||
|
||
hrv_baseline = safe_number(
|
||
baselines.get("hrv")
|
||
)
|
||
|
||
rhr_baseline = safe_number(
|
||
baselines.get("restingHeartRate")
|
||
)
|
||
|
||
stress_baseline = safe_number(
|
||
baselines.get("stress")
|
||
)
|
||
|
||
components = []
|
||
|
||
# HRV: 35 %
|
||
if hrv_value is not None and hrv_baseline:
|
||
ratio = hrv_value / hrv_baseline
|
||
|
||
score = 50 + ((ratio - 1) * 250)
|
||
|
||
components.append(
|
||
(
|
||
clamp(score, 0, 100),
|
||
0.35
|
||
)
|
||
)
|
||
|
||
# Vilopuls: 20 %
|
||
if rhr_value is not None and rhr_baseline:
|
||
difference = (
|
||
rhr_baseline - rhr_value
|
||
)
|
||
|
||
score = 50 + difference * 8
|
||
|
||
components.append(
|
||
(
|
||
clamp(score, 0, 100),
|
||
0.20
|
||
)
|
||
)
|
||
|
||
# Sömn: 25 %
|
||
if sleep_score is not None:
|
||
components.append(
|
||
(
|
||
clamp(sleep_score, 0, 100),
|
||
0.25
|
||
)
|
||
)
|
||
|
||
# Stress: 10 %
|
||
if (
|
||
stress_value is not None
|
||
and stress_baseline is not None
|
||
):
|
||
difference = (
|
||
stress_baseline - stress_value
|
||
)
|
||
|
||
score = 50 + difference * 3
|
||
|
||
components.append(
|
||
(
|
||
clamp(score, 0, 100),
|
||
0.10
|
||
)
|
||
)
|
||
|
||
# Body Battery: 10 %
|
||
if body_battery is not None:
|
||
components.append(
|
||
(
|
||
clamp(body_battery, 0, 100),
|
||
0.10
|
||
)
|
||
)
|
||
|
||
if not components:
|
||
return None
|
||
|
||
total_weight = sum(
|
||
weight
|
||
for _, weight in components
|
||
)
|
||
|
||
weighted_score = sum(
|
||
score * weight
|
||
for score, weight in components
|
||
)
|
||
|
||
recovery = (
|
||
weighted_score / total_weight
|
||
)
|
||
|
||
return round(
|
||
clamp(recovery, 0, 100)
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# STRAIN
|
||
#
|
||
# 0–21, inspirerat av Whoop men egen Vyta-modell.
|
||
#
|
||
# Garmin Training Load är primär signal.
|
||
# HR-zoner används som extra påverkan.
|
||
# ---------------------------------------------------------
|
||
|
||
def calculate_activity_strain(activity):
|
||
training_load = safe_number(
|
||
activity.get("activityTrainingLoad")
|
||
) or 0
|
||
|
||
duration_seconds = safe_number(
|
||
activity.get("durationSeconds")
|
||
) or 0
|
||
|
||
zones = activity.get("hrZones", {})
|
||
|
||
z1 = safe_number(
|
||
zones.get("zone1Seconds")
|
||
) or 0
|
||
|
||
z2 = safe_number(
|
||
zones.get("zone2Seconds")
|
||
) or 0
|
||
|
||
z3 = safe_number(
|
||
zones.get("zone3Seconds")
|
||
) or 0
|
||
|
||
z4 = safe_number(
|
||
zones.get("zone4Seconds")
|
||
) or 0
|
||
|
||
z5 = safe_number(
|
||
zones.get("zone5Seconds")
|
||
) or 0
|
||
|
||
# Viktad HR-belastning
|
||
hr_load = (
|
||
z1 * 1
|
||
+ z2 * 2
|
||
+ z3 * 3
|
||
+ z4 * 4
|
||
+ z5 * 5
|
||
) / 60
|
||
|
||
# Garmin Training Load bidrar mest.
|
||
#
|
||
# Logaritmisk kurva gör att strain blir
|
||
# svårare att öka ju högre den redan är.
|
||
base = (
|
||
training_load
|
||
+ hr_load * 0.18
|
||
+ (duration_seconds / 60) * 0.12
|
||
)
|
||
|
||
if base <= 0:
|
||
return 0
|
||
|
||
strain = (
|
||
6.2 * math.log10(1 + base)
|
||
)
|
||
|
||
return round(
|
||
clamp(strain, 0, 21),
|
||
1,
|
||
)
|
||
|
||
|
||
def calculate_daily_strain(
|
||
date_string,
|
||
activities
|
||
):
|
||
strains = []
|
||
|
||
for activity in activities:
|
||
if date_from_activity(activity) != date_string:
|
||
continue
|
||
|
||
strain = calculate_activity_strain(
|
||
activity
|
||
)
|
||
|
||
if strain > 0:
|
||
strains.append(strain)
|
||
|
||
if not strains:
|
||
return 0.0
|
||
|
||
# Flera aktiviteter samma dag ska inte bara
|
||
# summeras rakt av eftersom skalan är 0–21.
|
||
#
|
||
# Kombinerad exponentiell modell:
|
||
remaining = 1.0
|
||
|
||
for strain in strains:
|
||
fraction = clamp(
|
||
strain / 21,
|
||
0,
|
||
1
|
||
)
|
||
|
||
remaining *= (
|
||
1 - fraction
|
||
)
|
||
|
||
total = 21 * (
|
||
1 - remaining
|
||
)
|
||
|
||
return round(
|
||
clamp(total, 0, 21),
|
||
1,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# NORMALISERA AKTIVITETER
|
||
# ---------------------------------------------------------
|
||
|
||
def normalize_activity(activity):
|
||
duration_seconds = safe_number(
|
||
activity.get("durationSeconds")
|
||
)
|
||
|
||
distance = safe_number(
|
||
activity.get("distanceMeters")
|
||
)
|
||
|
||
avg_speed = safe_number(
|
||
activity.get("averageSpeed")
|
||
)
|
||
|
||
strain = calculate_activity_strain(
|
||
activity
|
||
)
|
||
|
||
return {
|
||
"id": str(
|
||
activity.get("id")
|
||
),
|
||
|
||
"name":
|
||
activity.get("name"),
|
||
|
||
"type":
|
||
activity.get("type"),
|
||
|
||
"startTime":
|
||
activity.get("startTimeLocal"),
|
||
|
||
"durationMinutes":
|
||
minutes(duration_seconds),
|
||
|
||
"distanceMeters":
|
||
round_or_none(distance),
|
||
|
||
"calories":
|
||
round_or_none(
|
||
activity.get("calories")
|
||
),
|
||
|
||
"averageHeartRate":
|
||
round_or_none(
|
||
activity.get(
|
||
"averageHeartRate"
|
||
)
|
||
),
|
||
|
||
"maxHeartRate":
|
||
round_or_none(
|
||
activity.get(
|
||
"maxHeartRate"
|
||
)
|
||
),
|
||
|
||
"averageSpeed":
|
||
round_or_none(
|
||
avg_speed,
|
||
2
|
||
),
|
||
|
||
"aerobicTrainingEffect":
|
||
round_or_none(
|
||
activity.get(
|
||
"aerobicTrainingEffect"
|
||
),
|
||
1,
|
||
),
|
||
|
||
"anaerobicTrainingEffect":
|
||
round_or_none(
|
||
activity.get(
|
||
"anaerobicTrainingEffect"
|
||
),
|
||
1,
|
||
),
|
||
|
||
"garminTrainingLoad":
|
||
round_or_none(
|
||
activity.get(
|
||
"activityTrainingLoad"
|
||
),
|
||
1,
|
||
),
|
||
|
||
"trainingEffectLabel":
|
||
activity.get(
|
||
"trainingEffectLabel"
|
||
),
|
||
|
||
"bodyBatteryImpact":
|
||
round_or_none(
|
||
activity.get(
|
||
"bodyBatteryImpact"
|
||
)
|
||
),
|
||
|
||
"strain":
|
||
strain,
|
||
|
||
"details":
|
||
activity.get(
|
||
"details"
|
||
),
|
||
|
||
"laps":
|
||
activity.get(
|
||
"laps"
|
||
),
|
||
|
||
"details":
|
||
activity.get(
|
||
"details"
|
||
),
|
||
|
||
"laps":
|
||
activity.get(
|
||
"laps"
|
||
),
|
||
|
||
"heartRateZones": {
|
||
"zone1Minutes":
|
||
round_or_none(
|
||
minutes(
|
||
activity
|
||
.get("hrZones", {})
|
||
.get("zone1Seconds")
|
||
),
|
||
1,
|
||
),
|
||
|
||
"zone2Minutes":
|
||
round_or_none(
|
||
minutes(
|
||
activity
|
||
.get("hrZones", {})
|
||
.get("zone2Seconds")
|
||
),
|
||
1,
|
||
),
|
||
|
||
"zone3Minutes":
|
||
round_or_none(
|
||
minutes(
|
||
activity
|
||
.get("hrZones", {})
|
||
.get("zone3Seconds")
|
||
),
|
||
1,
|
||
),
|
||
|
||
"zone4Minutes":
|
||
round_or_none(
|
||
minutes(
|
||
activity
|
||
.get("hrZones", {})
|
||
.get("zone4Seconds")
|
||
),
|
||
1,
|
||
),
|
||
|
||
"zone5Minutes":
|
||
round_or_none(
|
||
minutes(
|
||
activity
|
||
.get("hrZones", {})
|
||
.get("zone5Seconds")
|
||
),
|
||
1,
|
||
),
|
||
},
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# NORMALISERA DAG
|
||
# ---------------------------------------------------------
|
||
|
||
def normalize_day(
|
||
day,
|
||
activities,
|
||
baselines
|
||
):
|
||
date_string = day.get("date")
|
||
|
||
daily = day.get("daily", {})
|
||
sleep = day.get("sleep", {})
|
||
hrv = day.get("hrv", {})
|
||
stress = day.get("stress", {})
|
||
body_battery = day.get(
|
||
"bodyBattery",
|
||
{}
|
||
)
|
||
respiration = day.get(
|
||
"respiration",
|
||
{}
|
||
)
|
||
spo2 = day.get(
|
||
"spo2",
|
||
{}
|
||
)
|
||
training = day.get(
|
||
"training",
|
||
{}
|
||
)
|
||
|
||
recovery = calculate_recovery(
|
||
day,
|
||
baselines
|
||
)
|
||
|
||
strain = calculate_daily_strain(
|
||
date_string,
|
||
activities
|
||
)
|
||
|
||
sleep_duration = safe_number(
|
||
sleep.get("durationMinutes")
|
||
)
|
||
|
||
sleep_need = safe_number(
|
||
sleep.get("sleepNeedMinutes")
|
||
)
|
||
|
||
sleep_debt = None
|
||
|
||
if (
|
||
sleep_duration is not None
|
||
and sleep_need is not None
|
||
):
|
||
sleep_debt = max(
|
||
0,
|
||
round(
|
||
sleep_need -
|
||
sleep_duration
|
||
)
|
||
)
|
||
|
||
return {
|
||
"date":
|
||
date_string,
|
||
|
||
"recovery":
|
||
recovery,
|
||
|
||
"strain":
|
||
strain,
|
||
|
||
"heart": {
|
||
"restingHeartRate":
|
||
round_or_none(
|
||
daily.get(
|
||
"restingHeartRate"
|
||
)
|
||
),
|
||
|
||
"minHeartRate":
|
||
round_or_none(
|
||
daily.get(
|
||
"minHeartRate"
|
||
)
|
||
),
|
||
|
||
"maxHeartRate":
|
||
round_or_none(
|
||
daily.get(
|
||
"maxHeartRate"
|
||
)
|
||
),
|
||
|
||
"hrv":
|
||
round_or_none(
|
||
hrv.get(
|
||
"nightAverage"
|
||
)
|
||
),
|
||
},
|
||
|
||
"sleep": {
|
||
"score":
|
||
round_or_none(
|
||
sleep.get("score")
|
||
),
|
||
|
||
"durationMinutes":
|
||
round_or_none(
|
||
sleep_duration
|
||
),
|
||
|
||
"needMinutes":
|
||
round_or_none(
|
||
sleep_need
|
||
),
|
||
|
||
"debtMinutes":
|
||
sleep_debt,
|
||
|
||
"deepMinutes":
|
||
round_or_none(
|
||
sleep.get(
|
||
"deepMinutes"
|
||
)
|
||
),
|
||
|
||
"lightMinutes":
|
||
round_or_none(
|
||
sleep.get(
|
||
"lightMinutes"
|
||
)
|
||
),
|
||
|
||
"remMinutes":
|
||
round_or_none(
|
||
sleep.get(
|
||
"remMinutes"
|
||
)
|
||
),
|
||
|
||
"awakeMinutes":
|
||
round_or_none(
|
||
sleep.get(
|
||
"awakeMinutes"
|
||
)
|
||
),
|
||
|
||
"sleepStartTime":
|
||
sleep.get(
|
||
"sleepStartTimestampLocal"
|
||
),
|
||
|
||
"sleepEndTime":
|
||
sleep.get(
|
||
"sleepEndTimestampLocal"
|
||
),
|
||
},
|
||
|
||
"energy": {
|
||
"stress":
|
||
round_or_none(
|
||
stress.get("average")
|
||
or daily.get(
|
||
"averageStress"
|
||
)
|
||
),
|
||
|
||
"bodyBattery":
|
||
round_or_none(
|
||
daily.get(
|
||
"bodyBatteryCurrent"
|
||
)
|
||
),
|
||
|
||
"bodyBatteryHighest":
|
||
round_or_none(
|
||
daily.get(
|
||
"bodyBatteryHighest"
|
||
)
|
||
),
|
||
|
||
"bodyBatteryLowest":
|
||
round_or_none(
|
||
daily.get(
|
||
"bodyBatteryLowest"
|
||
)
|
||
),
|
||
|
||
"bodyBatteryCharged":
|
||
round_or_none(
|
||
body_battery.get(
|
||
"charged"
|
||
)
|
||
),
|
||
|
||
"bodyBatteryDrained":
|
||
round_or_none(
|
||
body_battery.get(
|
||
"drained"
|
||
)
|
||
),
|
||
},
|
||
|
||
"health": {
|
||
"spo2Average":
|
||
round_or_none(
|
||
spo2.get("average")
|
||
or daily.get(
|
||
"averageSpo2"
|
||
),
|
||
1,
|
||
),
|
||
|
||
"spo2Lowest":
|
||
round_or_none(
|
||
spo2.get(
|
||
"lowest"
|
||
),
|
||
1,
|
||
),
|
||
|
||
"respirationSleep":
|
||
round_or_none(
|
||
respiration.get(
|
||
"sleepAverage"
|
||
),
|
||
1,
|
||
),
|
||
|
||
"respirationAwake":
|
||
round_or_none(
|
||
respiration.get(
|
||
"awakeAverage"
|
||
),
|
||
1,
|
||
),
|
||
},
|
||
|
||
"activity": {
|
||
"steps":
|
||
round_or_none(
|
||
daily.get("steps")
|
||
),
|
||
|
||
"distanceMeters":
|
||
round_or_none(
|
||
daily.get(
|
||
"distanceMeters"
|
||
)
|
||
),
|
||
|
||
"totalCalories":
|
||
round_or_none(
|
||
daily.get(
|
||
"totalCalories"
|
||
)
|
||
),
|
||
|
||
"activeCalories":
|
||
round_or_none(
|
||
daily.get(
|
||
"activeCalories"
|
||
)
|
||
),
|
||
|
||
"moderateIntensityMinutes":
|
||
round_or_none(
|
||
daily.get(
|
||
"moderateIntensityMinutes"
|
||
)
|
||
),
|
||
|
||
"vigorousIntensityMinutes":
|
||
round_or_none(
|
||
daily.get(
|
||
"vigorousIntensityMinutes"
|
||
)
|
||
),
|
||
},
|
||
|
||
"training": {
|
||
"readiness":
|
||
round_or_none(
|
||
training.get(
|
||
"readiness"
|
||
)
|
||
),
|
||
|
||
"vo2max":
|
||
extract_vo2max(
|
||
training.get(
|
||
"vo2max"
|
||
)
|
||
),
|
||
|
||
"status":
|
||
training.get(
|
||
"status"
|
||
),
|
||
|
||
"loadBalance":
|
||
training.get(
|
||
"loadBalance"
|
||
),
|
||
|
||
"fitnessAge":
|
||
round_or_none(
|
||
training.get(
|
||
"fitnessAge"
|
||
),
|
||
1,
|
||
),
|
||
},
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# MAIN
|
||
# ---------------------------------------------------------
|
||
|
||
def main():
|
||
if not os.path.exists(
|
||
SOURCE_FILE
|
||
):
|
||
raise FileNotFoundError(
|
||
f"Hittar inte {SOURCE_FILE}"
|
||
)
|
||
|
||
print(
|
||
"Läser Garmin-data..."
|
||
)
|
||
|
||
with open(
|
||
SOURCE_FILE,
|
||
"r",
|
||
encoding="utf-8"
|
||
) as file:
|
||
source = json.load(file)
|
||
|
||
raw_days = source.get(
|
||
"days",
|
||
[]
|
||
)
|
||
|
||
raw_activities = source.get(
|
||
"activities",
|
||
[]
|
||
)
|
||
|
||
print(
|
||
f"Garmin-dagar: "
|
||
f"{len(raw_days)}"
|
||
)
|
||
|
||
print(
|
||
f"Garmin-aktiviteter: "
|
||
f"{len(raw_activities)}"
|
||
)
|
||
|
||
# Ignorera dagar som helt saknar data.
|
||
meaningful_days = [
|
||
day
|
||
for day in raw_days
|
||
if has_meaningful_data(day)
|
||
]
|
||
|
||
if not meaningful_days:
|
||
raise RuntimeError(
|
||
"Ingen användbar Garmin-data hittades."
|
||
)
|
||
|
||
baselines = calculate_baselines(
|
||
meaningful_days
|
||
)
|
||
|
||
normalized_days = [
|
||
normalize_day(
|
||
day,
|
||
raw_activities,
|
||
baselines
|
||
)
|
||
for day in meaningful_days
|
||
]
|
||
|
||
normalized_activities = [
|
||
normalize_activity(activity)
|
||
for activity in raw_activities
|
||
]
|
||
|
||
# Senaste dagen där vi åtminstone har
|
||
# användbar recovery-information.
|
||
recovery_candidates = [
|
||
day
|
||
for day in meaningful_days
|
||
if has_recovery_data(day)
|
||
]
|
||
|
||
if recovery_candidates:
|
||
latest_raw_day = (
|
||
recovery_candidates[-1]
|
||
)
|
||
else:
|
||
latest_raw_day = (
|
||
meaningful_days[-1]
|
||
)
|
||
|
||
latest_date = latest_raw_day.get(
|
||
"date"
|
||
)
|
||
|
||
today = next(
|
||
(
|
||
day
|
||
for day in normalized_days
|
||
if day.get("date")
|
||
== latest_date
|
||
),
|
||
normalized_days[-1],
|
||
)
|
||
|
||
today_activities = [
|
||
activity
|
||
for activity in normalized_activities
|
||
if (
|
||
activity.get(
|
||
"startTime",
|
||
""
|
||
)[:10]
|
||
== today["date"]
|
||
)
|
||
]
|
||
|
||
output = {
|
||
"meta": {
|
||
"generatedAt":
|
||
datetime.now().isoformat(),
|
||
|
||
"source":
|
||
"Garmin Connect",
|
||
|
||
"provider":
|
||
"python-garminconnect",
|
||
|
||
"latestDataDate":
|
||
today["date"],
|
||
|
||
"daysAvailable":
|
||
len(
|
||
normalized_days
|
||
),
|
||
},
|
||
|
||
"baselines":
|
||
baselines,
|
||
|
||
"today":
|
||
today,
|
||
|
||
"todayActivities":
|
||
today_activities,
|
||
|
||
"history":
|
||
normalized_days,
|
||
|
||
"activities":
|
||
normalized_activities,
|
||
}
|
||
|
||
os.makedirs(
|
||
OUTPUT_DIR,
|
||
exist_ok=True
|
||
)
|
||
|
||
with open(
|
||
OUTPUT_FILE,
|
||
"w",
|
||
encoding="utf-8"
|
||
) as file:
|
||
json.dump(
|
||
output,
|
||
file,
|
||
indent=2,
|
||
ensure_ascii=False,
|
||
default=str,
|
||
)
|
||
|
||
print()
|
||
print(
|
||
"=" * 70
|
||
)
|
||
print(
|
||
"VYTA NORMALIZER KLAR"
|
||
)
|
||
print(
|
||
"=" * 70
|
||
)
|
||
|
||
print(
|
||
f"Senaste dag: "
|
||
f"{today['date']}"
|
||
)
|
||
|
||
print(
|
||
f"Recovery: "
|
||
f"{today.get('recovery')}"
|
||
)
|
||
|
||
print(
|
||
f"Strain: "
|
||
f"{today.get('strain')}"
|
||
)
|
||
|
||
print(
|
||
f"HRV: "
|
||
f"{today.get('heart', {}).get('hrv')}"
|
||
)
|
||
|
||
print(
|
||
f"Vilopuls: "
|
||
f"{today.get('heart', {}).get('restingHeartRate')}"
|
||
)
|
||
|
||
print(
|
||
f"Sömnscore: "
|
||
f"{today.get('sleep', {}).get('score')}"
|
||
)
|
||
|
||
print()
|
||
print(
|
||
"Baselines:"
|
||
)
|
||
|
||
for key, value in baselines.items():
|
||
print(
|
||
f" {key}: {value}"
|
||
)
|
||
|
||
print()
|
||
print(
|
||
f"Sparad till:"
|
||
)
|
||
|
||
print(
|
||
OUTPUT_FILE
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
|
||
|