Skip to content

Commit d501129

Browse files
committed
feat: add speed metric extraction from TCX files
Implements parsing of speed data from ldn:SpeedMetersSec extensions in TCX files. Features namespace-aware XML parsing that works with any namespace prefix for the https://limulus.net/xsd/tcx/v1 namespace. - Add Speed enum value to SampleMetric - Implement namespace-aware speed parsing in TCXReader - Add comprehensive test coverage for speed extraction - Add @sindresorhus/is dependency for runtime type assertions - Create test fixture for different namespace prefixes
1 parent ddfaf87 commit d501129

File tree

6 files changed

+143
-6
lines changed

6 files changed

+143
-6
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<TrainingCenterDatabase
3+
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
4+
xmlns:limulus="https://limulus.net/xsd/tcx/v1"
5+
>
6+
<Activities>
7+
<Activity Sport="Biking">
8+
<Id>2025-03-02T18:38:01Z</Id>
9+
<Lap StartTime="2025-03-02T18:38:01Z">
10+
<TotalTimeSeconds>10</TotalTimeSeconds>
11+
<DistanceMeters>100</DistanceMeters>
12+
<Calories>10</Calories>
13+
<Intensity>Active</Intensity>
14+
<TriggerMethod>Manual</TriggerMethod>
15+
<Track>
16+
<Trackpoint>
17+
<Time>2025-03-02T19:00:33.999</Time>
18+
<Position>
19+
<LatitudeDegrees>32.36415566566089</LatitudeDegrees>
20+
<LongitudeDegrees>-111.01644143335649</LongitudeDegrees>
21+
</Position>
22+
<AltitudeMeters>714.6836803928018</AltitudeMeters>
23+
<Extensions>
24+
<limulus:SpeedMetersSec>8.12345</limulus:SpeedMetersSec>
25+
<limulus:CourseDegrees>213.60538018719149</limulus:CourseDegrees>
26+
</Extensions>
27+
</Trackpoint>
28+
</Track>
29+
</Lap>
30+
</Activity>
31+
</Activities>
32+
</TrainingCenterDatabase>

package-lock.json

Lines changed: 17 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
},
4747
"homepage": "https://github.com/limulus/tcx2webvtt#readme",
4848
"dependencies": {
49+
"@sindresorhus/is": "^7.0.2",
4950
"es-main": "^1.3.0",
5051
"fast-xml-parser": "^5.2.3",
5152
"prettier": "^3.5.3",

src/lib/sample.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export enum SampleMetric {
33
Distance = 'distance',
44
Cadence = 'cadence',
55
Power = 'power',
6+
Speed = 'speed',
67
Location = 'location',
78
}
89

src/lib/tcx-reader.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,56 @@ describe('TCXReader', () => {
167167
firstLocationSample.value.longitude
168168
)
169169
}
170+
171+
// Check for speed samples
172+
const speedSamples = samples.filter((s) => s.metric === SampleMetric.Speed)
173+
expect(speedSamples.length).toBeGreaterThan(0)
174+
175+
// Check first speed sample value matches the first trackpoint with speed data
176+
const firstSpeedSample = speedSamples[0] as Sample<SampleMetric.Speed>
177+
expect(firstSpeedSample.value).toBeCloseTo(6.57443257931449)
178+
})
179+
180+
it('should handle TCX content with different namespace prefix', async () => {
181+
const tcxContent = await fs.readFile(
182+
join(fixturesDir, 'different-namespace-prefix.tcx'),
183+
'utf8'
184+
)
185+
const reader = new TCXReader(tcxContent)
186+
const samples = reader.getSamples()
187+
188+
expect(samples).toBeInstanceOf(Array)
189+
expect(samples.length).toBeGreaterThan(0)
190+
191+
// Check for speed samples with different namespace prefix
192+
const speedSamples = samples.filter((s) => s.metric === SampleMetric.Speed)
193+
expect(speedSamples.length).toBe(1)
194+
195+
// Check speed value from different namespace prefix
196+
const speedSample = speedSamples[0] as Sample<SampleMetric.Speed>
197+
expect(speedSample.value).toBeCloseTo(8.12345)
198+
199+
// Also verify location sample exists
200+
const locationSamples = samples.filter((s) => s.metric === SampleMetric.Location)
201+
expect(locationSamples.length).toBe(1)
202+
})
203+
204+
it('should handle TCX content without ldn namespace', async () => {
205+
const tcxContent = await fs.readFile(join(fixturesDir, 'concept2.tcx'), 'utf8')
206+
const reader = new TCXReader(tcxContent)
207+
208+
// This tests the case where the ldn namespace is not found
209+
// The speed parsing should return null and no speed samples should be created
210+
const samples = reader.getSamples()
211+
const speedSamples = samples.filter((s) => s.metric === SampleMetric.Speed)
212+
213+
// concept2.tcx doesn't have ldn namespace, so no speed samples
214+
expect(speedSamples.length).toBe(0)
215+
216+
// But it should have other samples
217+
expect(samples.length).toBeGreaterThan(0)
218+
const heartRateSamples = samples.filter((s) => s.metric === SampleMetric.HeartRate)
219+
expect(heartRateSamples.length).toBeGreaterThan(0)
170220
})
171221
})
172222
})

src/lib/tcx-reader.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { assertObject } from '@sindresorhus/is'
12
import { XMLParser } from 'fast-xml-parser'
23

34
import { Sample, SampleMetric, Coordinates } from './sample.js'
45

56
export class TCXReader {
67
private readonly parser: XMLParser
78
private readonly xmlData: any
9+
private readonly ldnNamespaceURI = 'https://limulus.net/xsd/tcx/v1'
810

911
constructor(tcxContent: string) {
1012
this.parser = new XMLParser({
@@ -14,6 +16,40 @@ export class TCXReader {
1416
this.xmlData = this.parser.parse(tcxContent)
1517
}
1618

19+
private findNamespacePrefix(namespaceURI: string): string | null {
20+
const rootElement = this.xmlData?.TrainingCenterDatabase
21+
22+
assertObject(
23+
rootElement,
24+
'TrainingCenterDatabase must be a valid object for namespace lookup'
25+
)
26+
27+
// Look for namespace declarations
28+
for (const [key, value] of Object.entries(rootElement)) {
29+
if (key.startsWith('@_xmlns:') && value === namespaceURI) {
30+
return key.substring('@_xmlns:'.length)
31+
}
32+
}
33+
return null
34+
}
35+
36+
private getSpeedFromExtensions(extensions: any): number | null {
37+
if (!extensions) return null
38+
39+
const ldnPrefix = this.findNamespacePrefix(this.ldnNamespaceURI)
40+
if (ldnPrefix === null) return null
41+
42+
const speedValue = extensions[`${ldnPrefix}:SpeedMetersSec`]
43+
44+
if (speedValue !== undefined && speedValue !== null && speedValue !== '') {
45+
const speed = parseFloat(String(speedValue))
46+
if (!isNaN(speed)) {
47+
return speed
48+
}
49+
}
50+
return null
51+
}
52+
1753
public getSamples(): Sample<SampleMetric>[] {
1854
const activities = this.xmlData?.TrainingCenterDatabase?.Activities?.Activity
1955
if (!activities) {
@@ -108,6 +144,12 @@ export class TCXReader {
108144
}
109145
}
110146
}
147+
148+
// Parse speed from ldn namespace extensions if available
149+
const speed = this.getSpeedFromExtensions(tp.Extensions)
150+
if (speed !== null) {
151+
allSamples.push(new Sample<SampleMetric.Speed>(time, SampleMetric.Speed, speed))
152+
}
111153
}
112154
}
113155
}

0 commit comments

Comments
 (0)