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()