first commit
This commit is contained in:
Executable
+285
@@ -0,0 +1,285 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user