Files
2025-11-13 21:11:40 +01:00

286 lines
9.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""
TCX Parser - Extraherar träningsdata från TCX-filer
Skapar kompakt JSON-sammanfattning för analys
Användning:
python3 parse_tcx.py <tcx-fil>
python3 parse_tcx.py Data/training/activity_12345.tcx
Output:
Skapar <tcx-fil>.json med sammanfattad data
"""
import xml.etree.ElementTree as ET
import json
import sys
from pathlib import Path
from datetime import datetime
# Namespace definitions för TCX
NAMESPACES = {
'tcx': 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2',
'ns3': 'http://www.garmin.com/xmlschemas/ActivityExtension/v2'
}
def parse_tcx_file(tcx_path):
"""Parsar TCX-fil och extraherar all relevant data"""
tree = ET.parse(tcx_path)
root = tree.getroot()
# Hitta aktiviteten
activity = root.find('.//tcx:Activity', NAMESPACES)
if activity is None:
raise ValueError("Ingen aktivitet hittades i TCX-filen")
# Grundläggande info
sport = activity.get('Sport', 'Unknown')
activity_id = activity.find('tcx:Id', NAMESPACES).text
# Samla data från varv
laps = activity.findall('.//tcx:Lap', NAMESPACES)
lap_data = []
total_time = 0
total_distance = 0
total_calories = 0
max_speed = 0
for i, lap in enumerate(laps, 1):
lap_info = {
'number': i,
'start_time': lap.get('StartTime'),
}
# Extrahera varvdata
time_elem = lap.find('tcx:TotalTimeSeconds', NAMESPACES)
dist_elem = lap.find('tcx:DistanceMeters', NAMESPACES)
cal_elem = lap.find('tcx:Calories', NAMESPACES)
max_speed_elem = lap.find('tcx:MaximumSpeed', NAMESPACES)
avg_hr_elem = lap.find('.//tcx:AverageHeartRateBpm/tcx:Value', NAMESPACES)
max_hr_elem = lap.find('.//tcx:MaximumHeartRateBpm/tcx:Value', NAMESPACES)
avg_cad_elem = lap.find('tcx:Cadence', NAMESPACES)
if time_elem is not None:
lap_time = float(time_elem.text)
lap_info['time_seconds'] = lap_time
total_time += lap_time
if dist_elem is not None:
lap_dist = float(dist_elem.text)
lap_info['distance_meters'] = lap_dist
total_distance += lap_dist
if cal_elem is not None:
lap_cal = int(cal_elem.text)
lap_info['calories'] = lap_cal
total_calories += lap_cal
if max_speed_elem is not None:
lap_max_speed = float(max_speed_elem.text)
lap_info['max_speed_ms'] = lap_max_speed
max_speed = max(max_speed, lap_max_speed)
if avg_hr_elem is not None:
lap_info['avg_hr'] = int(avg_hr_elem.text)
if max_hr_elem is not None:
lap_info['max_hr'] = int(max_hr_elem.text)
if avg_cad_elem is not None:
lap_info['avg_cadence'] = int(avg_cad_elem.text)
lap_data.append(lap_info)
# Samla data från trackpoints
trackpoints = root.findall('.//tcx:Trackpoint', NAMESPACES)
hr_data = []
power_data = []
cadence_data = []
altitude_data = []
speed_data = []
for tp in trackpoints:
# Puls
hr_elem = tp.find('.//tcx:HeartRateBpm/tcx:Value', NAMESPACES)
if hr_elem is not None:
hr_data.append(int(hr_elem.text))
# Effekt
power_elem = tp.find('.//ns3:Watts', NAMESPACES)
if power_elem is not None:
power_data.append(int(power_elem.text))
# Kadans
cad_elem = tp.find('tcx:Cadence', NAMESPACES)
if cad_elem is not None:
cadence_data.append(int(cad_elem.text))
# Höjd
alt_elem = tp.find('tcx:AltitudeMeters', NAMESPACES)
if alt_elem is not None:
altitude_data.append(float(alt_elem.text))
# Hastighet
speed_elem = tp.find('.//ns3:Speed', NAMESPACES)
if speed_elem is not None:
speed_data.append(float(speed_elem.text))
# Beräkna pulsstatistik och zoner
hr_stats = None
hr_zones = None
if hr_data:
avg_hr = sum(hr_data) / len(hr_data)
min_hr = min(hr_data)
max_hr = max(hr_data)
# Pulszoner (baserat på 5-zonssystem)
# Kan justeras baserat på individuell maxpuls
zone1 = sum(1 for hr in hr_data if hr < 110)
zone2 = sum(1 for hr in hr_data if 110 <= hr < 129)
zone3 = sum(1 for hr in hr_data if 129 <= hr < 147)
zone4 = sum(1 for hr in hr_data if 147 <= hr < 166)
zone5 = sum(1 for hr in hr_data if hr >= 166)
total_points = len(hr_data)
hr_stats = {
'avg': round(avg_hr, 1),
'min': min_hr,
'max': max_hr,
}
hr_zones = {
'zone1_pct': round(zone1 / total_points * 100, 1),
'zone2_pct': round(zone2 / total_points * 100, 1),
'zone3_pct': round(zone3 / total_points * 100, 1),
'zone4_pct': round(zone4 / total_points * 100, 1),
'zone5_pct': round(zone5 / total_points * 100, 1),
'note': 'Zoner baserade på: Z1<110, Z2:110-129, Z3:129-147, Z4:147-166, Z5>166 bpm'
}
# Beräkna effektstatistik
power_stats = None
if power_data:
power_stats = {
'avg': round(sum(power_data) / len(power_data), 1),
'min': min(power_data),
'max': max(power_data),
}
# Beräkna kadansstatistik
cadence_stats = None
if cadence_data:
cadence_stats = {
'avg': round(sum(cadence_data) / len(cadence_data), 1),
'min': min(cadence_data),
'max': max(cadence_data),
}
# Beräkna höjdstatistik
altitude_stats = None
if altitude_data:
elevation_gain = 0
for i in range(1, len(altitude_data)):
diff = altitude_data[i] - altitude_data[i-1]
if diff > 0:
elevation_gain += diff
altitude_stats = {
'min': round(min(altitude_data), 1),
'max': round(max(altitude_data), 1),
'gain': round(elevation_gain, 1),
}
# Beräkna hastighetsstatistik
speed_stats = None
if speed_data:
avg_speed = sum(speed_data) / len(speed_data)
speed_stats = {
'avg_ms': round(avg_speed, 2),
'avg_kmh': round(avg_speed * 3.6, 1),
'max_ms': round(max(speed_data), 2),
'max_kmh': round(max(speed_data) * 3.6, 1),
}
# Bygg sammanfattning
summary = {
'file': str(tcx_path),
'parsed_at': datetime.now().isoformat(),
'activity': {
'sport': sport,
'id': activity_id,
'date': activity_id.split('T')[0] if 'T' in activity_id else activity_id,
},
'summary': {
'total_time_seconds': round(total_time, 1),
'total_time_formatted': f"{int(total_time//60)}:{int(total_time%60):02d}",
'total_distance_meters': round(total_distance, 2),
'total_distance_km': round(total_distance / 1000, 2),
'avg_speed_kmh': round((total_distance / total_time) * 3.6, 1) if total_time > 0 else 0,
'max_speed_kmh': round(max_speed * 3.6, 1),
'total_calories': total_calories,
'num_laps': len(laps),
'num_trackpoints': len(trackpoints),
},
'heart_rate': hr_stats,
'heart_rate_zones': hr_zones,
'power': power_stats,
'cadence': cadence_stats,
'altitude': altitude_stats,
'speed': speed_stats,
'laps': lap_data,
}
return summary
def main():
if len(sys.argv) < 2:
print("Användning: python3 parse_tcx.py <tcx-fil>")
print("Exempel: python3 parse_tcx.py Data/training/activity_12345.tcx")
sys.exit(1)
tcx_file = Path(sys.argv[1])
if not tcx_file.exists():
print(f"Fel: Filen {tcx_file} hittades inte")
sys.exit(1)
try:
print(f"Parsar {tcx_file}...")
summary = parse_tcx_file(tcx_file)
# Spara som JSON
output_file = tcx_file.with_suffix('.tcx.json')
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
print(f"✓ Klar! Sammanfattning sparad i: {output_file}")
print(f"\nSnabb sammanfattning:")
print(f" Sport: {summary['activity']['sport']}")
print(f" Datum: {summary['activity']['date']}")
print(f" Tid: {summary['summary']['total_time_formatted']}")
print(f" Distans: {summary['summary']['total_distance_km']} km")
print(f" Genomsnittshastighet: {summary['summary']['avg_speed_kmh']} km/h")
if summary['heart_rate']:
print(f" Genomsnittspuls: {summary['heart_rate']['avg']} bpm")
print(f" Max puls: {summary['heart_rate']['max']} bpm")
if summary['power']:
print(f" Genomsnittseffekt: {summary['power']['avg']} W")
print(f"\nAnvänd denna JSON-fil för snabb analys istället för stora TCX-filen!")
except Exception as e:
print(f"Fel vid parsning: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()