OK, so I’ve managed to bodge something together that mostly does what I want. It screams “temporary workaround”, because that’s exactly what it is.
Here is my workflow:
- Download TCX from Garmin Connect
- Run script that inserts power data into TCX file
- Upload TCX to intervals.icu
- Set original powerless activity settings to ignore time, distance, load, HR, etc data to avoid double counting effort (I could move the original, but I’m keeping it there just in case)
The reason I use a TCX is because it is human readable, and FIT files are a bit of a pain to hack around in. The TCX is quite nice, because I don’t even need to parse it as XML. If you flick through it line by line, you find the speed information is stored in a TrackPoint with the handy tag of “ns3:Speed”:
<Trackpoint>
<Time>2023-10-20T07:30:17.000Z</Time>
<DistanceMeters>7.039999961853027</DistanceMeters>
<HeartRateBpm>
<Value>98</Value>
</HeartRateBpm>
<Cadence>49</Cadence>
<Extensions>
<ns3:TPX>
<ns3:Speed>2.4070000648498535</ns3:Speed>
</ns3:TPX>
</Extensions>
</Trackpoint>
My script runs through the TCX line by line, and when it finds a line with “ns3:Speed”, it grabs the speed value, uses it to estimate power (using the power curve for my dumb trainer), and inserts the line back into the file with a “ns3:Watts” tag:
<Trackpoint>
<Time>2023-10-20T07:30:17.000Z</Time>
<DistanceMeters>7.039999961853027</DistanceMeters>
<HeartRateBpm>
<Value>98</Value>
</HeartRateBpm>
<Cadence>49</Cadence>
<Extensions>
<ns3:TPX>
<ns3:Speed>2.4070000648498535</ns3:Speed>
<ns3:Watts>44.668474977210856</ns3:Watts>
</ns3:TPX>
</Extensions>
</Trackpoint>
It rattles through the whole file, adding Watts where it finds Speed, and I can just throw this into intervals.icu and see power analysis.
It is a bit manual at the moment, because I haven’t looked at the Connect and intervals.icu APIs to try and fully automate this process. But given that this is likely a temporary workaround, I may not bother fully automating it.
Script below if anybody is interested:
#!/usr/bin/env python3
"""
Hack/bodge to insert power estimate into a TCX indoor cycling file
Makes loads of glorious assumptions
"""
import argparse
import logging
import re
def main(infile):
"""
main : main function
Really, it's the only function.
"""
outfile = re.sub(r'\.tcx', '_pwr.tcx', infile)
activity_file = []
logging.info('Reading activity from %s', infile)
with open(infile, 'r', encoding='utf-8') as infile_f:
for line in infile_f:
activity_file.append(line)
re_speed = re.search(r'<ns3:Speed>([\d\.]+)</ns3:Speed>', line)
if re_speed is None:
continue
logging.debug('Matched speed line, attaching power')
speed = float(re_speed.group(1)) * 3.6 # m/s -> km/h
power = speed*(0.072258*speed + 4.528797)
power_line = re.sub(r'<ns3:Speed>([\d\.]+)</ns3:Speed>',
f'<ns3:Watts>{power}</ns3:Watts>',
line)
logging.debug(line)
logging.debug(power_line)
activity_file.append(power_line)
logging.info('Writing activity to %s', outfile)
with open(outfile, 'w', encoding='utf-8') as outfile_f:
for line in activity_file:
outfile_f.write(line)
def _argparser():
"""
Wrapper for argparse
"""
parser = argparse.ArgumentParser(prog='set_power',
description='Hack/bodge to insert power estimate into a TCX indoor cycling file')
parser.add_argument('infile')
parser.add_argument('-v', '--verbose',
action='store_true', help='Increase verbosity')
return parser.parse_args()
if __name__ == '__main__':
args = _argparser()
if args.verbose:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
main(args.infile)