Files

1253 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()