TCX Training Data in Java: Processing Fitness and GPS Workouts

TCX (Training Center XML) is a Garmin-developed format for storing fitness training data, including GPS tracks, heart rate, cadence, power, and other workout metrics. Java provides robust tools for parsing, processing, and analyzing TCX files, enabling developers to build fitness applications, training analysis tools, and data visualization platforms. This guide covers practical patterns for working with TCX data in Java applications.

Understanding TCX File Structure

Key TCX Components:

  • Activities with sport type and session data
  • Laps containing track segments and summary metrics
  • Trackpoints with timestamped GPS and sensor data
  • Heart Rate, Cadence, Power measurements
  • Extensions for custom data fields

Core Implementation Patterns

1. Project Setup and Dependencies

Configure XML processing and fitness data dependencies.

Maven Configuration:

<dependencies>
<!-- XML Processing -->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.8</version>
</dependency>
<!-- Date/Time -->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.12.5</version>
</dependency>
<!-- Spatial Calculations -->
<dependency>
<groupId>org.locationtech.jts</groupId>
<artifactId>jts-core</artifactId>
<version>1.19.0</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<!-- Web Framework -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.1.0</version>
</dependency>
</dependencies>

2. TCX Domain Models

Create comprehensive Java models for TCX data structures.

Core Domain Models:

@Data
public class TCXActivity {
private String id;
private SportType sport;
private LocalDateTime startTime;
private List<ActivityLap> laps;
private Creator creator;
private Map<String, Object> extensions;
public Duration getTotalTime() {
return laps.stream()
.map(ActivityLap::getTotalTimeSeconds)
.map(seconds -> Duration.ofSeconds(seconds.longValue()))
.reduce(Duration.ZERO, Duration::plus);
}
public double getTotalDistance() {
return laps.stream()
.mapToDouble(ActivityLap::getDistanceMeters)
.sum();
}
public double getTotalCalories() {
return laps.stream()
.mapToDouble(ActivityLap::getCalories)
.sum();
}
public List<Trackpoint> getAllTrackpoints() {
return laps.stream()
.flatMap(lap -> lap.getTrackpoints().stream())
.collect(Collectors.toList());
}
public BoundingBox getBoundingBox() {
List<Trackpoint> points = getAllTrackpoints();
if (points.isEmpty()) return null;
double minLat = points.stream()
.mapToDouble(tp -> tp.getPosition().getLatitude())
.min().orElse(0);
double maxLat = points.stream()
.mapToDouble(tp -> tp.getPosition().getLatitude())
.max().orElse(0);
double minLon = points.stream()
.mapToDouble(tp -> tp.getPosition().getLongitude())
.min().orElse(0);
double maxLon = points.stream()
.mapToDouble(tp -> tp.getPosition().getLongitude())
.max().orElse(0);
return new BoundingBox(minLat, minLon, maxLat, maxLon);
}
}
@Data
public class ActivityLap {
private LocalDateTime startTime;
private Double totalTimeSeconds;
private Double distanceMeters;
private Double maximumSpeed;
private Double calories;
private Double averageHeartRate;
private Double maximumHeartRate;
private String intensity;
private String triggerMethod;
private List<Trackpoint> trackpoints;
private LapExtensions extensions;
public double getAverageSpeed() {
if (totalTimeSeconds == null || totalTimeSeconds == 0) return 0;
return distanceMeters / totalTimeSeconds;
}
public double getPaceMinutesPerKm() {
double speed = getAverageSpeed();
if (speed == 0) return 0;
return (1000 / speed) / 60; // minutes per kilometer
}
}
@Data
public class Trackpoint {
private LocalDateTime time;
private Position position;
private Double altitudeMeters;
private Double distanceMeters;
private Integer heartRateBpm;
private Integer cadence;
private Double speed;
private Integer power;
private Double temperature;
private Map<String, Object> extensions;
public boolean hasPosition() {
return position != null && position.isValid();
}
public boolean hasHeartRate() {
return heartRateBpm != null;
}
public boolean hasCadence() {
return cadence != null;
}
}
@Data
public class Position {
private Double latitude;
private Double longitude;
public boolean isValid() {
return latitude != null && longitude != null &&
latitude >= -90 && latitude <= 90 &&
longitude >= -180 && longitude <= 180;
}
public double distanceTo(Position other) {
if (!isValid() || !other.isValid()) return 0;
// Haversine formula for great-circle distance
double lat1 = Math.toRadians(latitude);
double lon1 = Math.toRadians(longitude);
double lat2 = Math.toRadians(other.latitude);
double lon2 = Math.toRadians(other.longitude);
double dlat = lat2 - lat1;
double dlon = lon2 - lon1;
double a = Math.sin(dlat/2) * Math.sin(dlat/2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(dlon/2) * Math.sin(dlon/2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return 6371000 * c; // Earth radius in meters
}
}
@Data
public class BoundingBox {
private double minLatitude;
private double minLongitude;
private double maxLatitude;
private double maxLongitude;
public BoundingBox(double minLat, double minLon, double maxLat, double maxLon) {
this.minLatitude = minLat;
this.minLongitude = minLon;
this.maxLatitude = maxLat;
this.maxLongitude = maxLon;
}
public boolean contains(Position position) {
return position.getLatitude() >= minLatitude &&
position.getLatitude() <= maxLatitude &&
position.getLongitude() >= minLongitude &&
position.getLongitude() <= maxLongitude;
}
public Position getCenter() {
Position center = new Position();
center.setLatitude((minLatitude + maxLatitude) / 2);
center.setLongitude((minLongitude + maxLongitude) / 2);
return center;
}
}
@Data
public class LapExtensions {
private Double avgSpeed;
private Double maxSpeed;
private Double avgPower;
private Double maxPower;
private Double avgCadence;
private Double maxCadence;
private Map<String, Object> customFields;
}
@Data
public class Creator {
private String name;
private String version;
private String productId;
}
public enum SportType {
RUNNING("Running"),
CYCLING("Biking"),
SWIMMING("Swimming"),
WALKING("Walking"),
HIKING("Hiking"),
TRIATHLON("Triathlon"),
OTHER("Other");
private final String displayName;
SportType(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
public static SportType fromString(String value) {
for (SportType sport : values()) {
if (sport.name().equalsIgnoreCase(value) || 
sport.displayName.equalsIgnoreCase(value)) {
return sport;
}
}
return OTHER;
}
}

3. TCX Parser Service

Implement TCX file parsing using JAXB or DOM parsing.

TCX Parser Service:

@Service
@Slf4j
public class TCXParserService {
private final ObjectMapper objectMapper;
public TCXParserService() {
this.objectMapper = new ObjectMapper();
this.objectMapper.registerModule(new JavaTimeModule());
this.objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
public TCXActivity parseTCXFile(String filePath) throws TCXParseException {
try {
File file = new File(filePath);
if (!file.exists()) {
throw new TCXParseException("TCX file not found: " + filePath);
}
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(file);
return parseDocument(document);
} catch (Exception e) {
throw new TCXParseException("Failed to parse TCX file: " + filePath, e);
}
}
public TCXActivity parseTCXContent(String tcxContent) throws TCXParseException {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(new InputSource(new StringReader(tcxContent)));
return parseDocument(document);
} catch (Exception e) {
throw new TCXParseException("Failed to parse TCX content", e);
}
}
private TCXActivity parseDocument(Document document) throws TCXParseException {
try {
TCXActivity activity = new TCXActivity();
NodeList activityNodes = document.getElementsByTagName("Activity");
if (activityNodes.getLength() == 0) {
throw new TCXParseException("No Activity element found in TCX file");
}
Element activityElement = (Element) activityNodes.item(0);
// Parse activity attributes
activity.setSport(SportType.fromString(activityElement.getAttribute("Sport")));
activity.setId(getElementText(activityElement, "Id"));
// Parse start time
String startTimeStr = getElementText(activityElement, "Id");
if (startTimeStr != null) {
activity.setStartTime(parseDateTime(startTimeStr));
}
// Parse creator
activity.setCreator(parseCreator(activityElement));
// Parse laps
activity.setLaps(parseLaps(activityElement));
return activity;
} catch (Exception e) {
throw new TCXParseException("Failed to parse TCX document", e);
}
}
private List<ActivityLap> parseLaps(Element activityElement) {
List<ActivityLap> laps = new ArrayList<>();
NodeList lapNodes = activityElement.getElementsByTagName("Lap");
for (int i = 0; i < lapNodes.getLength(); i++) {
Element lapElement = (Element) lapNodes.item(i);
ActivityLap lap = new ActivityLap();
// Parse lap attributes
lap.setStartTime(parseDateTime(lapElement.getAttribute("StartTime")));
lap.setTotalTimeSeconds(getDoubleValue(lapElement, "TotalTimeSeconds"));
lap.setDistanceMeters(getDoubleValue(lapElement, "DistanceMeters"));
lap.setMaximumSpeed(getDoubleValue(lapElement, "MaximumSpeed"));
lap.setCalories(getDoubleValue(lapElement, "Calories"));
// Parse heart rate values
Element avgHr = getChildElement(lapElement, "AverageHeartRateBpm");
if (avgHr != null) {
lap.setAverageHeartRate(getDoubleValue(avgHr, "Value"));
}
Element maxHr = getChildElement(lapElement, "MaximumHeartRateBpm");
if (maxHr != null) {
lap.setMaximumHeartRate(getDoubleValue(maxHr, "Value"));
}
lap.setIntensity(getElementText(lapElement, "Intensity"));
lap.setTriggerMethod(getElementText(lapElement, "TriggerMethod"));
// Parse trackpoints
lap.setTrackpoints(parseTrackpoints(lapElement));
// Parse extensions
lap.setExtensions(parseLapExtensions(lapElement));
laps.add(lap);
}
return laps;
}
private List<Trackpoint> parseTrackpoints(Element lapElement) {
List<Trackpoint> trackpoints = new ArrayList<>();
NodeList tpNodes = lapElement.getElementsByTagName("Trackpoint");
for (int i = 0; i < tpNodes.getLength(); i++) {
Element tpElement = (Element) tpNodes.item(i);
Trackpoint trackpoint = new Trackpoint();
// Parse time
String timeStr = getElementText(tpElement, "Time");
if (timeStr != null) {
trackpoint.setTime(parseDateTime(timeStr));
}
// Parse position
Element positionElement = getChildElement(tpElement, "Position");
if (positionElement != null) {
Position position = new Position();
position.setLatitude(getDoubleValue(positionElement, "LatitudeDegrees"));
position.setLongitude(getDoubleValue(positionElement, "LongitudeDegrees"));
trackpoint.setPosition(position);
}
// Parse other values
trackpoint.setAltitudeMeters(getDoubleValue(tpElement, "AltitudeMeters"));
trackpoint.setDistanceMeters(getDoubleValue(tpElement, "DistanceMeters"));
// Parse heart rate
Element hrElement = getChildElement(tpElement, "HeartRateBpm");
if (hrElement != null) {
trackpoint.setHeartRateBpm(getIntegerValue(hrElement, "Value"));
}
trackpoint.setCadence(getIntegerValue(tpElement, "Cadence"));
trackpoint.setSpeed(getDoubleValue(tpElement, "Speed"));
trackpoint.setPower(getIntegerValue(tpElement, "Watts"));
// Parse extensions
trackpoint.setExtensions(parseTrackpointExtensions(tpElement));
trackpoints.add(trackpoint);
}
return trackpoints;
}
private LapExtensions parseLapExtensions(Element lapElement) {
Element extensionsElement = getChildElement(lapElement, "Extensions");
if (extensionsElement == null) return null;
LapExtensions extensions = new LapExtensions();
Map<String, Object> customFields = new HashMap<>();
// Parse LX extensions (common in Garmin files)
Element lxElement = getChildElement(extensionsElement, "LX");
if (lxElement != null) {
extensions.setAvgSpeed(getDoubleValue(lxElement, "AvgSpeed"));
extensions.setMaxSpeed(getDoubleValue(lxElement, "MaxSpeed"));
extensions.setAvgPower(getDoubleValue(lxElement, "AvgWatts"));
extensions.setMaxPower(getDoubleValue(lxElement, "MaxWatts"));
}
// Parse TPX extensions (Trackpoint extensions)
NodeList tpxNodes = extensionsElement.getElementsByTagName("TPX");
for (int i = 0; i < tpxNodes.getLength(); i++) {
Element tpxElement = (Element) tpxNodes.item(i);
extensions.setAvgCadence(getDoubleValue(tpxElement, "RunCadence"));
extensions.setMaxCadence(getDoubleValue(tpxElement, "MaxRunCadence"));
}
extensions.setCustomFields(customFields);
return extensions;
}
private Map<String, Object> parseTrackpointExtensions(Element trackpointElement) {
Element extensionsElement = getChildElement(trackpointElement, "Extensions");
if (extensionsElement == null) return null;
Map<String, Object> extensions = new HashMap<>();
// Parse TPX extensions
Element tpxElement = getChildElement(extensionsElement, "TPX");
if (tpxElement != null) {
extensions.put("speed", getDoubleValue(tpxElement, "Speed"));
extensions.put("watts", getDoubleValue(tpxElement, "Watts"));
extensions.put("runCadence", getDoubleValue(tpxElement, "RunCadence"));
extensions.put("temperature", getDoubleValue(tpxElement, "Temp"));
}
return extensions;
}
private Creator parseCreator(Element activityElement) {
Element creatorElement = getChildElement(activityElement, "Creator");
if (creatorElement == null) return null;
Creator creator = new Creator();
creator.setName(creatorElement.getAttribute("Name"));
Element versionElement = getChildElement(creatorElement, "Version");
if (versionElement != null) {
creator.setVersion(getElementText(versionElement, "VersionMajor") + "." +
getElementText(versionElement, "VersionMinor"));
creator.setProductId(getElementText(versionElement, "ProductID"));
}
return creator;
}
// Helper methods for XML parsing
private String getElementText(Element parent, String tagName) {
Element element = getChildElement(parent, tagName);
return element != null ? element.getTextContent().trim() : null;
}
private Double getDoubleValue(Element parent, String tagName) {
String text = getElementText(parent, tagName);
return text != null ? Double.parseDouble(text) : null;
}
private Integer getIntegerValue(Element parent, String tagName) {
String text = getElementText(parent, tagName);
return text != null ? Integer.parseInt(text) : null;
}
private Element getChildElement(Element parent, String tagName) {
NodeList nodes = parent.getElementsByTagName(tagName);
return nodes.getLength() > 0 ? (Element) nodes.item(0) : null;
}
private LocalDateTime parseDateTime(String dateTimeStr) {
try {
// TCX uses ISO 8601 format: 2023-10-15T14:30:45.000Z
return LocalDateTime.parse(dateTimeStr, 
DateTimeFormatter.ISO_DATE_TIME);
} catch (DateTimeParseException e) {
log.warn("Failed to parse date time: {}", dateTimeStr);
return null;
}
}
}
public class TCXParseException extends Exception {
public TCXParseException(String message) {
super(message);
}
public TCXParseException(String message, Throwable cause) {
super(message, cause);
}
}

4. TCX Analysis Service

Implement analysis and metrics calculation for TCX data.

TCX Analysis Service:

@Service
@Slf4j
public class TCXAnalysisService {
public ActivityAnalysis analyzeActivity(TCXActivity activity) {
ActivityAnalysis analysis = new ActivityAnalysis();
analysis.setActivity(activity);
// Basic metrics
analysis.setTotalDistance(activity.getTotalDistance());
analysis.setTotalTime(activity.getTotalTime());
analysis.setTotalCalories(activity.getTotalCalories());
// Calculate derived metrics
analysis.setAverageSpeed(calculateAverageSpeed(activity));
analysis.setMaxSpeed(calculateMaxSpeed(activity));
analysis.setAverageHeartRate(calculateAverageHeartRate(activity));
analysis.setMaxHeartRate(calculateMaxHeartRate(activity));
analysis.setAverageCadence(calculateAverageCadence(activity));
analysis.setElevationGain(calculateElevationGain(activity));
// Calculate zones and segments
analysis.setHeartRateZones(calculateHeartRateZones(activity));
analysis.setSpeedSegments(calculateSpeedSegments(activity));
analysis.setPowerAnalysis(analyzePowerData(activity));
// GPS analysis
analysis.setGpsAccuracy(calculateGpsAccuracy(activity));
analysis.setMovingTime(calculateMovingTime(activity));
return analysis;
}
public List<ActivityLap> detectAutoLaps(TCXActivity activity, double lapDistance) {
List<ActivityLap> autoLaps = new ArrayList<>();
List<Trackpoint> allPoints = activity.getAllTrackpoints();
if (allPoints.isEmpty() || lapDistance <= 0) {
return autoLaps;
}
double currentLapDistance = 0;
List<Trackpoint> currentLapPoints = new ArrayList<>();
LocalDateTime lapStartTime = allPoints.get(0).getTime();
for (Trackpoint point : allPoints) {
currentLapPoints.add(point);
if (point.getDistanceMeters() != null) {
currentLapDistance = point.getDistanceMeters() - 
(autoLaps.isEmpty() ? 0 : 
autoLaps.get(autoLaps.size() - 1).getDistanceMeters());
}
if (currentLapDistance >= lapDistance) {
ActivityLap lap = createLapFromPoints(currentLapPoints, lapStartTime);
autoLaps.add(lap);
currentLapPoints = new ArrayList<>();
lapStartTime = point.getTime();
currentLapDistance = 0;
}
}
// Add final lap
if (!currentLapPoints.isEmpty()) {
ActivityLap lap = createLapFromPoints(currentLapPoints, lapStartTime);
autoLaps.add(lap);
}
return autoLaps;
}
public PowerAnalysis analyzePowerData(TCXActivity activity) {
PowerAnalysis powerAnalysis = new PowerAnalysis();
List<Trackpoint> points = activity.getAllTrackpoints().stream()
.filter(tp -> tp.getPower() != null && tp.getPower() > 0)
.collect(Collectors.toList());
if (points.isEmpty()) {
return powerAnalysis;
}
// Calculate power statistics
DoubleSummaryStatistics stats = points.stream()
.mapToDouble(Trackpoint::getPower)
.summaryStatistics();
powerAnalysis.setAveragePower(stats.getAverage());
powerAnalysis.setMaxPower(stats.getMax());
powerAnalysis.setNormalizedPower(calculateNormalizedPower(points));
powerAnalysis.setVariabilityIndex(calculateVariabilityIndex(points));
powerAnalysis.setIntensityFactor(calculateIntensityFactor(points));
// Calculate power zones
powerAnalysis.setPowerZones(calculatePowerZones(points));
return powerAnalysis;
}
public List<HeartRateZone> calculateHeartRateZones(TCXActivity activity) {
List<HeartRateZone> zones = new ArrayList<>();
List<Trackpoint> points = activity.getAllTrackpoints().stream()
.filter(Trackpoint::hasHeartRate)
.collect(Collectors.toList());
if (points.isEmpty()) {
return zones;
}
// Define HR zones (these would typically be user-specific)
int[] zoneThresholds = {50, 60, 70, 80, 90}; // % of max HR
double maxHr = calculateMaxHeartRate(activity);
for (int i = 0; i < zoneThresholds.length; i++) {
int lowerBound = (int) (maxHr * zoneThresholds[i] / 100);
int upperBound = i < zoneThresholds.length - 1 ? 
(int) (maxHr * zoneThresholds[i + 1] / 100) : Integer.MAX_VALUE;
long timeInZone = points.stream()
.filter(tp -> tp.getHeartRateBpm() >= lowerBound && 
tp.getHeartRateBpm() < upperBound)
.count();
HeartRateZone zone = new HeartRateZone();
zone.setZoneNumber(i + 1);
zone.setLowerBound(lowerBound);
zone.setUpperBound(upperBound);
zone.setTimeInZone(Duration.ofSeconds(timeInZone));
zone.setPercentage((double) timeInZone / points.size() * 100);
zones.add(zone);
}
return zones;
}
public GpsAnalysis calculateGpsAccuracy(TCXActivity activity) {
GpsAnalysis analysis = new GpsAnalysis();
List<Trackpoint> points = activity.getAllTrackpoints().stream()
.filter(Trackpoint::hasPosition)
.collect(Collectors.toList());
if (points.size() < 2) {
return analysis;
}
// Calculate total distance from GPS points
double gpsDistance = 0;
for (int i = 1; i < points.size(); i++) {
Position prev = points.get(i - 1).getPosition();
Position curr = points.get(i).getPosition();
gpsDistance += prev.distanceTo(curr);
}
// Compare with device-calculated distance
double deviceDistance = activity.getTotalDistance();
double accuracy = deviceDistance > 0 ? 
(gpsDistance / deviceDistance) * 100 : 100;
analysis.setGpsDistance(gpsDistance);
analysis.setDeviceDistance(deviceDistance);
analysis.setAccuracyPercentage(accuracy);
analysis.setTotalPoints(points.size());
analysis.setSamplingRate(calculateSamplingRate(points));
return analysis;
}
private double calculateAverageSpeed(TCXActivity activity) {
Duration totalTime = activity.getTotalTime();
if (totalTime.isZero()) return 0;
return activity.getTotalDistance() / totalTime.getSeconds();
}
private double calculateMaxSpeed(TCXActivity activity) {
return activity.getAllTrackpoints().stream()
.filter(tp -> tp.getSpeed() != null)
.mapToDouble(Trackpoint::getSpeed)
.max()
.orElse(0);
}
private double calculateAverageHeartRate(TCXActivity activity) {
return activity.getAllTrackpoints().stream()
.filter(Trackpoint::hasHeartRate)
.mapToInt(Trackpoint::getHeartRateBpm)
.average()
.orElse(0);
}
private double calculateMaxHeartRate(TCXActivity activity) {
return activity.getAllTrackpoints().stream()
.filter(Trackpoint::hasHeartRate)
.mapToInt(Trackpoint::getHeartRateBpm)
.max()
.orElse(0);
}
private double calculateAverageCadence(TCXActivity activity) {
return activity.getAllTrackpoints().stream()
.filter(Trackpoint::hasCadence)
.mapToInt(Trackpoint::getCadence)
.average()
.orElse(0);
}
private double calculateElevationGain(TCXActivity activity) {
List<Trackpoint> points = activity.getAllTrackpoints().stream()
.filter(tp -> tp.getAltitudeMeters() != null)
.collect(Collectors.toList());
double gain = 0;
for (int i = 1; i < points.size(); i++) {
double prevAlt = points.get(i - 1).getAltitudeMeters();
double currAlt = points.get(i).getAltitudeMeters();
if (currAlt > prevAlt) {
gain += (currAlt - prevAlt);
}
}
return gain;
}
private Duration calculateMovingTime(TCXActivity activity) {
// Simple moving time calculation based on speed threshold
long movingPoints = activity.getAllTrackpoints().stream()
.filter(tp -> tp.getSpeed() != null && tp.getSpeed() > 1.0) // > 1 m/s
.count();
if (activity.getAllTrackpoints().isEmpty()) return Duration.ZERO;
double totalSeconds = activity.getTotalTime().getSeconds();
double movingRatio = (double) movingPoints / activity.getAllTrackpoints().size();
return Duration.ofSeconds((long) (totalSeconds * movingRatio));
}
private double calculateSamplingRate(List<Trackpoint> points) {
if (points.size() < 2) return 0;
long totalSeconds = 0;
for (int i = 1; i < points.size(); i++) {
Duration between = Duration.between(
points.get(i - 1).getTime(), 
points.get(i).getTime()
);
totalSeconds += between.getSeconds();
}
return (double) totalSeconds / (points.size() - 1);
}
private ActivityLap createLapFromPoints(List<Trackpoint> points, LocalDateTime startTime) {
ActivityLap lap = new ActivityLap();
lap.setStartTime(startTime);
lap.setTrackpoints(points);
// Calculate lap metrics
if (!points.isEmpty()) {
Duration lapDuration = Duration.between(
points.get(0).getTime(), 
points.get(points.size() - 1).getTime()
);
lap.setTotalTimeSeconds((double) lapDuration.getSeconds());
// Distance
Double startDistance = points.get(0).getDistanceMeters();
Double endDistance = points.get(points.size() - 1).getDistanceMeters();
if (startDistance != null && endDistance != null) {
lap.setDistanceMeters(endDistance - startDistance);
}
// Heart rate
double avgHr = points.stream()
.filter(Trackpoint::hasHeartRate)
.mapToInt(Trackpoint::getHeartRateBpm)
.average()
.orElse(0);
lap.setAverageHeartRate(avgHr);
double maxHr = points.stream()
.filter(Trackpoint::hasHeartRate)
.mapToInt(Trackpoint::getHeartRateBpm)
.max()
.orElse(0);
lap.setMaximumHeartRate(maxHr);
}
return lap;
}
// Power analysis helper methods
private double calculateNormalizedPower(List<Trackpoint> powerPoints) {
// 30-second rolling average
List<Double> rollingAverages = new ArrayList<>();
int windowSize = 30; // 30 seconds
for (int i = 0; i <= powerPoints.size() - windowSize; i++) {
double average = powerPoints.subList(i, i + windowSize).stream()
.mapToDouble(Trackpoint::getPower)
.average()
.orElse(0);
rollingAverages.add(average);
}
// Fourth power average
double fourthPowerAvg = rollingAverages.stream()
.mapToDouble(p -> Math.pow(p, 4))
.average()
.orElse(0);
return Math.pow(fourthPowerAvg, 0.25);
}
private double calculateVariabilityIndex(List<Trackpoint> powerPoints) {
double normalizedPower = calculateNormalizedPower(powerPoints);
double averagePower = powerPoints.stream()
.mapToDouble(Trackpoint::getPower)
.average()
.orElse(0);
return normalizedPower / averagePower;
}
private double calculateIntensityFactor(List<Trackpoint> powerPoints) {
double normalizedPower = calculateNormalizedPower(powerPoints);
double thresholdPower = 250; // This should be user's FTP
return normalizedPower / thresholdPower;
}
private Map<Integer, Duration> calculatePowerZones(List<Trackpoint> powerPoints) {
Map<Integer, Duration> zones = new HashMap<>();
double ftp = 250; // Functional Threshold Power
// Define power zones as percentages of FTP
Map<Integer, PowerZone> zoneDefinitions = Map.of(
1, new PowerZone(0, 0.55 * ftp, "Active Recovery"),
2, new PowerZone(0.56 * ftp, 0.75 * ftp, "Endurance"),
3, new PowerZone(0.76 * ftp, 0.90 * ftp, "Tempo"),
4, new PowerZone(0.91 * ftp, 1.05 * ftp, "Threshold"),
5, new PowerZone(1.06 * ftp, 1.20 * ftp, "VO2 Max"),
6, new PowerZone(1.21 * ftp, Double.MAX_VALUE, "Anaerobic")
);
for (Trackpoint point : powerPoints) {
double power = point.getPower();
for (Map.Entry<Integer, PowerZone> entry : zoneDefinitions.entrySet()) {
PowerZone zone = entry.getValue();
if (power >= zone.getMin() && power < zone.getMax()) {
zones.merge(entry.getKey(), Duration.ofSeconds(1), Duration::plus);
break;
}
}
}
return zones;
}
private List<SpeedSegment> calculateSpeedSegments(TCXActivity activity) {
// This would implement segment detection based on speed patterns
return new ArrayList<>(); // Simplified for example
}
}
@Data
public class ActivityAnalysis {
private TCXActivity activity;
private double totalDistance;
private Duration totalTime;
private double totalCalories;
private double averageSpeed;
private double maxSpeed;
private double averageHeartRate;
private double maxHeartRate;
private double averageCadence;
private double elevationGain;
private Duration movingTime;
private List<HeartRateZone> heartRateZones;
private List<SpeedSegment> speedSegments;
private PowerAnalysis powerAnalysis;
private GpsAnalysis gpsAccuracy;
public double getPaceMinutesPerKm() {
if (averageSpeed == 0) return 0;
return (1000 / averageSpeed) / 60;
}
public double getEfficiency() {
if (totalCalories == 0) return 0;
return totalDistance / totalCalories * 1000; // meters per calorie
}
}
@Data
public class HeartRateZone {
private int zoneNumber;
private int lowerBound;
private int upperBound;
private Duration timeInZone;
private double percentage;
private String description;
}
@Data
public class PowerAnalysis {
private double averagePower;
private double maxPower;
private double normalizedPower;
private double variabilityIndex;
private double intensityFactor;
private Map<Integer, Duration> powerZones;
}
@Data 
public class PowerZone {
private double min;
private double max;
private String description;
public PowerZone(double min, double max, String description) {
this.min = min;
this.max = max;
this.description = description;
}
}
@Data
public class GpsAnalysis {
private double gpsDistance;
private double deviceDistance;
private double accuracyPercentage;
private int totalPoints;
private double samplingRate;
}
@Data
public class SpeedSegment {
private int segmentNumber;
private LocalDateTime startTime;
private LocalDateTime endTime;
private double distance;
private Duration duration;
private double averageSpeed;
private double maxSpeed;
private double averageHeartRate;
}

5. TCX Export and Generation

Generate TCX files from Java objects.

TCX Generator Service:

@Service
public class TCXGeneratorService {
public String generateTCX(TCXActivity activity) {
StringBuilder tcx = new StringBuilder();
tcx.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
tcx.append("<TrainingCenterDatabase xmlns=\"http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2\"\n");
tcx.append("  xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n");
tcx.append("  xsi:schemaLocation=\"http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2\n");
tcx.append("  http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd\">\n");
tcx.append("<Activities>\n");
tcx.append(generateActivity(activity));
tcx.append("</Activities>\n");
tcx.append("</TrainingCenterDatabase>");
return tcx.toString();
}
private String generateActivity(TCXActivity activity) {
StringBuilder sb = new StringBuilder();
sb.append(String.format("<Activity Sport=\"%s\">\n", activity.getSport()));
sb.append(String.format("<Id>%s</Id>\n", 
formatDateTime(activity.getStartTime())));
// Generate laps
for (ActivityLap lap : activity.getLaps()) {
sb.append(generateLap(lap));
}
// Generate creator
if (activity.getCreator() != null) {
sb.append(generateCreator(activity.getCreator()));
}
sb.append("</Activity>\n");
return sb.toString();
}
private String generateLap(ActivityLap lap) {
StringBuilder sb = new StringBuilder();
sb.append(String.format("<Lap StartTime=\"%s\">\n", 
formatDateTime(lap.getStartTime())));
if (lap.getTotalTimeSeconds() != null) {
sb.append(String.format("<TotalTimeSeconds>%.1f</TotalTimeSeconds>\n", 
lap.getTotalTimeSeconds()));
}
if (lap.getDistanceMeters() != null) {
sb.append(String.format("<DistanceMeters>%.1f</DistanceMeters>\n", 
lap.getDistanceMeters()));
}
if (lap.getMaximumSpeed() != null) {
sb.append(String.format("<MaximumSpeed>%.2f</MaximumSpeed>\n", 
lap.getMaximumSpeed()));
}
if (lap.getCalories() != null) {
sb.append(String.format("<Calories>%.0f</Calories>\n", lap.getCalories()));
}
// Heart rate
if (lap.getAverageHeartRate() != null) {
sb.append("<AverageHeartRateBpm><Value>")
.append(lap.getAverageHeartRate().intValue())
.append("</Value></AverageHeartRateBpm>\n");
}
if (lap.getMaximumHeartRate() != null) {
sb.append("<MaximumHeartRateBpm><Value>")
.append(lap.getMaximumHeartRate().intValue())
.append("</Value></MaximumHeartRateBpm>\n");
}
if (lap.getIntensity() != null) {
sb.append(String.format("<Intensity>%s</Intensity>\n", lap.getIntensity()));
}
if (lap.getTriggerMethod() != null) {
sb.append(String.format("<TriggerMethod>%s</TriggerMethod>\n", 
lap.getTriggerMethod()));
}
// Trackpoints
sb.append("<Track>\n");
for (Trackpoint point : lap.getTrackpoints()) {
sb.append(generateTrackpoint(point));
}
sb.append("</Track>\n");
// Extensions
if (lap.getExtensions() != null) {
sb.append(generateLapExtensions(lap.getExtensions()));
}
sb.append("</Lap>\n");
return sb.toString();
}
private String generateTrackpoint(Trackpoint point) {
StringBuilder sb = new StringBuilder();
sb.append("<Trackpoint>\n");
sb.append(String.format("<Time>%s</Time>\n", formatDateTime(point.getTime())));
if (point.getPosition() != null) {
sb.append("<Position>\n");
sb.append(String.format("<LatitudeDegrees>%.6f</LatitudeDegrees>\n", 
point.getPosition().getLatitude()));
sb.append(String.format("<LongitudeDegrees>%.6f</LongitudeDegrees>\n", 
point.getPosition().getLongitude()));
sb.append("</Position>\n");
}
if (point.getAltitudeMeters() != null) {
sb.append(String.format("<AltitudeMeters>%.1f</AltitudeMeters>\n", 
point.getAltitudeMeters()));
}
if (point.getDistanceMeters() != null) {
sb.append(String.format("<DistanceMeters>%.1f</DistanceMeters>\n", 
point.getDistanceMeters()));
}
if (point.getHeartRateBpm() != null) {
sb.append("<HeartRateBpm><Value>")
.append(point.getHeartRateBpm())
.append("</Value></HeartRateBpm>\n");
}
if (point.getCadence() != null) {
sb.append(String.format("<Cadence>%d</Cadence>\n", point.getCadence()));
}
if (point.getExtensions() != null && !point.getExtensions().isEmpty()) {
sb.append(generateTrackpointExtensions(point.getExtensions()));
}
sb.append("</Trackpoint>\n");
return sb.toString();
}
private String generateCreator(Creator creator) {
StringBuilder sb = new StringBuilder();
sb.append("<Creator>\n");
sb.append(String.format("<Name>%s</Name>\n", creator.getName()));
if (creator.getVersion() != null) {
String[] versionParts = creator.getVersion().split("\\.");
sb.append("<Version>\n");
sb.append(String.format("<VersionMajor>%s</VersionMajor>\n", 
versionParts.length > 0 ? versionParts[0] : "0"));
sb.append(String.format("<VersionMinor>%s</VersionMinor>\n", 
versionParts.length > 1 ? versionParts[1] : "0"));
if (creator.getProductId() != null) {
sb.append(String.format("<ProductID>%s</ProductID>\n", creator.getProductId()));
}
sb.append("</Version>\n");
}
sb.append("</Creator>\n");
return sb.toString();
}
private String generateLapExtensions(LapExtensions extensions) {
StringBuilder sb = new StringBuilder();
sb.append("<Extensions>\n");
sb.append("<LX>\n");
if (extensions.getAvgSpeed() != null) {
sb.append(String.format("<AvgSpeed>%.2f</AvgSpeed>\n", extensions.getAvgSpeed()));
}
if (extensions.getMaxSpeed() != null) {
sb.append(String.format("<MaxSpeed>%.2f</MaxSpeed>\n", extensions.getMaxSpeed()));
}
if (extensions.getAvgPower() != null) {
sb.append(String.format("<AvgWatts>%.0f</AvgWatts>\n", extensions.getAvgPower()));
}
if (extensions.getMaxPower() != null) {
sb.append(String.format("<MaxWatts>%.0f</MaxWatts>\n", extensions.getMaxPower()));
}
sb.append("</LX>\n");
sb.append("</Extensions>\n");
return sb.toString();
}
private String generateTrackpointExtensions(Map<String, Object> extensions) {
StringBuilder sb = new StringBuilder();
sb.append("<Extensions>\n");
sb.append("<TPX>\n");
if (extensions.containsKey("speed")) {
sb.append(String.format("<Speed>%.2f</Speed>\n", extensions.get("speed")));
}
if (extensions.containsKey("watts")) {
sb.append(String.format("<Watts>%.0f</Watts>\n", extensions.get("watts")));
}
if (extensions.containsKey("runCadence")) {
sb.append(String.format("<RunCadence>%.0f</RunCadence>\n", extensions.get("runCadence")));
}
sb.append("</TPX>\n");
sb.append("</Extensions>\n");
return sb.toString();
}
private String formatDateTime(LocalDateTime dateTime) {
if (dateTime == null) return "";
return dateTime.format(DateTimeFormatter.ISO_DATE_TIME) + "Z";
}
public void writeTCXToFile(TCXActivity activity, String filePath) throws IOException {
String tcxContent = generateTCX(activity);
Files.write(Paths.get(filePath), tcxContent.getBytes(StandardCharsets.UTF_8));
}
}

6. REST API for TCX Processing

Expose TCX processing as web services.

TCX Controller:

@RestController
@RequestMapping("/api/tcx")
@Slf4j
public class TCXController {
private final TCXParserService parserService;
private final TCXAnalysisService analysisService;
private final TCXGeneratorService generatorService;
public TCXController(TCXParserService parserService,
TCXAnalysisService analysisService,
TCXGeneratorService generatorService) {
this.parserService = parserService;
this.analysisService = analysisService;
this.generatorService = generatorService;
}
@PostMapping("/parse")
public ResponseEntity<TCXActivity> parseTCX(@RequestBody ParseTCXRequest request) {
try {
TCXActivity activity;
if (request.getFileContent() != null) {
activity = parserService.parseTCXContent(request.getFileContent());
} else if (request.getFilePath() != null) {
activity = parserService.parseTCXFile(request.getFilePath());
} else {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(activity);
} catch (TCXParseException e) {
log.error("TCX parsing failed", e);
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/analyze")
public ResponseEntity<ActivityAnalysis> analyzeActivity(@RequestBody TCXActivity activity) {
try {
ActivityAnalysis analysis = analysisService.analyzeActivity(activity);
return ResponseEntity.ok(analysis);
} catch (Exception e) {
log.error("Activity analysis failed", e);
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/generate")
public ResponseEntity<String> generateTCX(@RequestBody TCXActivity activity) {
try {
String tcxContent = generatorService.generateTCX(activity);
return ResponseEntity.ok()
.header("Content-Type", "application/xml")
.header("Content-Disposition", "attachment; filename=\"activity.tcx\"")
.body(tcxContent);
} catch (Exception e) {
log.error("TCX generation failed", e);
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/laps/detect")
public ResponseEntity<List<ActivityLap>> detectLaps(
@RequestBody DetectLapsRequest request) {
try {
List<ActivityLap> autoLaps = analysisService.detectAutoLaps(
request.getActivity(), request.getLapDistance());
return ResponseEntity.ok(autoLaps);
} catch (Exception e) {
log.error("Lap detection failed", e);
return ResponseEntity.badRequest().build();
}
}
@GetMapping("/sports")
public ResponseEntity<List<SportType>> getSportTypes() {
return ResponseEntity.ok(Arrays.asList(SportType.values()));
}
}
@Data
class ParseTCXRequest {
private String filePath;
private String fileContent;
}
@Data
class DetectLapsRequest {
private TCXActivity activity;
private double lapDistance = 1000; // meters
}

Best Practices for TCX Processing

  1. Error Handling: Implement comprehensive error handling for malformed TCX files
  2. Memory Management: Use streaming parsing for large TCX files to avoid memory issues
  3. Data Validation: Validate TCX data consistency and completeness
  4. Performance: Cache analysis results for frequently accessed activities
  5. Standard Compliance: Ensure generated TCX files comply with Garmin specifications
  6. Unit Testing: Test with various TCX files from different devices and software
  7. Data Privacy: Handle personal fitness data with appropriate security measures

Conclusion: Comprehensive Fitness Data Processing

TCX processing in Java enables developers to build sophisticated fitness applications that can parse, analyze, and generate training data. By implementing robust parsing, detailed analysis, and standard-compliant generation, Java applications can provide valuable insights for athletes, coaches, and fitness enthusiasts.

This implementation demonstrates that TCX isn't just a data format—it's a gateway to understanding athletic performance, enabling features like advanced metrics calculation, training zone analysis, and personalized workout recommendations that help users optimize their training and achieve their fitness goals.

Java Observability, Logging Intelligence & AI-Driven Monitoring (APM, Tracing, Logs & Anomaly Detection)

https://macronepal.com/blog/beyond-metrics-observing-serverless-and-traditional-java-applications-with-thundra-apm/
Explains using Thundra APM to observe both serverless and traditional Java applications by combining tracing, metrics, and logs into a unified observability platform for faster debugging and performance insights.

https://macronepal.com/blog/dynatrace-oneagent-in-java-2/
Explains Dynatrace OneAgent for Java, which automatically instruments JVM applications to capture metrics, traces, and logs, enabling full-stack monitoring and root-cause analysis with minimal configuration.

https://macronepal.com/blog/lightstep-java-sdk-distributed-tracing-and-observability-implementation/
Explains Lightstep Java SDK for distributed tracing, helping developers track requests across microservices and identify latency issues using OpenTelemetry-based observability.

https://macronepal.com/blog/honeycomb-io-beeline-for-java-complete-guide-2/
Explains Honeycomb Beeline for Java, which provides high-cardinality observability and deep query capabilities to understand complex system behavior and debug distributed systems efficiently.

https://macronepal.com/blog/lumigo-for-serverless-in-java-complete-distributed-tracing-guide-2/
Explains Lumigo for Java serverless applications, offering automatic distributed tracing, log correlation, and error tracking to simplify debugging in cloud-native environments. (Lumigo Docs)

https://macronepal.com/blog/from-noise-to-signals-implementing-log-anomaly-detection-in-java-applications/
Explains how to detect anomalies in Java logs using behavioral patterns and machine learning techniques to separate meaningful incidents from noisy log data and improve incident response.

https://macronepal.com/blog/ai-powered-log-analysis-in-java-from-reactive-debugging-to-proactive-insights/
Explains AI-driven log analysis for Java applications, shifting from manual debugging to predictive insights that identify issues early and improve system reliability using intelligent log processing.

https://macronepal.com/blog/titliel-java-logging-best-practices/
Explains best practices for Java logging, focusing on structured logs, proper log levels, performance optimization, and ensuring logs are useful for debugging and observability systems.

https://macronepal.com/blog/seeking-a-loguru-for-java-the-quest-for-elegant-and-simple-logging/
Explains the search for simpler, more elegant logging frameworks in Java, comparing modern logging approaches that aim to reduce complexity while improving readability and developer experience.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper