package com.awsmithson.tcx2nikeplus.convert;
import com.awsmithson.tcx2nikeplus.http.Geonames;
import com.awsmithson.tcx2nikeplus.nike.RunJson;
import com.awsmithson.tcx2nikeplus.util.Log;
import com.garmin.xmlschemas.trackpointextension.v1.TrackPointExtensionT;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterators;
import com.google.common.collect.Lists;
import com.google.common.primitives.Doubles;
import com.spatial4j.core.context.SpatialContext;
import com.spatial4j.core.distance.DistanceCalculator;
import com.spatial4j.core.distance.DistanceUtils;
import com.spatial4j.core.distance.GeodesicSphereDistCalc;
import com.spatial4j.core.shape.Point;
import com.spatial4j.core.shape.impl.PointImpl;
import com.topografix.gpx._1._1.ExtensionsType;
import com.topografix.gpx._1._1.GpxType;
import com.topografix.gpx._1._1.TrkType;
import com.topografix.gpx._1._1.TrksegType;
import com.topografix.gpx._1._1.WptType;
import org.apache.commons.math3.analysis.interpolation.SplineInterpolator;
import org.apache.commons.math3.analysis.polynomials.PolynomialSplineFunction;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.TimeZone;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.xml.bind.JAXBElement;
import javax.xml.datatype.XMLGregorianCalendar;
public class GpxToRunJson implements Converter<GpxType, RunJson> {
private static final @Nonnull Log logger = Log.getInstance();
private static final @Nonnull List<RunJson.Summary.DeviceConfig> DEFAULT_DEVICE_CONFIGS = ImmutableList.of(new RunJson.Summary.DeviceConfig(new RunJson.Summary.DeviceConfig.Component("iphone", "device")));
private static final @Nonnull String METRIC_DATA_POINT = "dataPoint";
/*
Required output for json:
distance - km
duration - ms
starttime - ms since epoch.
timeZoneId - Europe/London
type - run
*/
@Override
public RunJson convert(@Nonnull GpxType gpxDocument) throws ConverterException {
Preconditions.checkNotNull(gpxDocument, "gpxDocument argument is null.");
logger.out("Converting GpxType to RunJson");
SplineFunctions splineFunctions = generateSplines(gpxDocument);
ImmutableList.Builder<RunJson.Detail> runJsonDetailBuilder = ImmutableList.<RunJson.Detail>builder().add(
generateDetail("distance", "time", "sec", 0L, 10L, "dataStream", splineFunctions.durationToDistance, 3)
);
if (splineFunctions.durationToHeartRate != null) {
runJsonDetailBuilder.add(
generateDetail("heartrate", "time", "sec", 0L, 10L, "dataStream", splineFunctions.durationToHeartRate, 0)
);
}
RunJson.Summary runJsonSummary = new RunJson.Summary(
ImmutableList.of(
generateSnaphot("mileSplit", splineFunctions.distanceToDuration, DistanceUtils.MILES_TO_KM),
generateSnaphot("kmSplit", splineFunctions.distanceToDuration, 1)
),
DEFAULT_DEVICE_CONFIGS
);
BigDecimal maxDistance = new BigDecimal(getMaxX(splineFunctions.distanceToDuration)).setScale(6, RoundingMode.HALF_EVEN);
BigDecimal maxDuration = new BigDecimal(getMaxX(splineFunctions.durationToDistance)).setScale(6, RoundingMode.HALF_EVEN);
WptType firstTrkpt = getFirstTrkpt(gpxDocument);
try {
TimeZone timezone = Geonames.getTimeZone(firstTrkpt.getLon(), firstTrkpt.getLat());
long startTime = firstTrkpt.getTime().toGregorianCalendar().getTimeInMillis();
return new RunJson(maxDistance, maxDuration, startTime, "complete", timezone.getID(), "run", runJsonDetailBuilder.build(), runJsonSummary);
} catch (Throwable throwable) {
throw new ConverterException(throwable.getMessage(), throwable);
}
}
private double getMaxX(@Nonnull PolynomialSplineFunction splineFunction) {
double[] knots = splineFunction.getKnots();
return knots[knots.length - 1];
}
private @Nonnull RunJson.Detail generateDetail(@Nonnull String metricType,
@Nonnull String intervalType,
@Nonnull String intervalUnit,
long startTimeOffset,
long intervalMetric,
@Nonnull String objType,
@Nonnull PolynomialSplineFunction splineFunction,
int roundingScale) {
logger.out(" - generating detail for %s", metricType);
double[] knots = splineFunction.getKnots();
double maxValue = knots[knots.length - 1];
// IntervalMetric is seconds, we have our data stored as milliseconds, so multiply by 1000.
long loopIncrement = intervalMetric * 1000;
List<BigDecimal> values = new ArrayList<>();
for (long i = loopIncrement; i <= maxValue; i += loopIncrement) {
BigDecimal value = new BigDecimal(splineFunction.value(i));
values.add(value.setScale(roundingScale, RoundingMode.HALF_EVEN));
}
return new RunJson.Detail(metricType, intervalType, intervalUnit, startTimeOffset, String.valueOf(intervalMetric), objType, values);
}
private @Nonnull RunJson.Summary.Snapshot generateSnaphot(@Nonnull String name, @Nonnull PolynomialSplineFunction splineFunction, double metricInterval) {
logger.out(" - generating snapshot for %s", name);
double[] knots = splineFunction.getKnots();
int dataSeriesLength = (int) (knots[knots.length - 1] / metricInterval);
List<RunJson.Summary.Snapshot.DataSeries> dataSeries = new ArrayList<>(dataSeriesLength);
for (int i = 1; i <= dataSeriesLength; ++i) {
long value = (long) splineFunction.value(i * metricInterval);
dataSeries.add(createDataSeries(i, value, METRIC_DATA_POINT));
}
return new RunJson.Summary.Snapshot(name, dataSeries);
}
private @Nonnull RunJson.Summary.Snapshot.DataSeries createDataSeries(int distance, long duration, @Nonnull String objType) {
RunJson.Summary.Snapshot.DataSeries.Metrics metric = new RunJson.Summary.Snapshot.DataSeries.Metrics(distance, duration);
return new RunJson.Summary.Snapshot.DataSeries(metric, objType);
}
private @Nonnull WptType getFirstTrkpt(@Nonnull GpxType gpxDocument) {
for (TrkType trks : gpxDocument.getTrk()) {
for (TrksegType trkSeg : trks.getTrkseg()) {
for (WptType trkpt : trkSeg.getTrkpt()) {
if (isValidTrkpt(trkpt)) {
return trkpt;
}
}
}
}
throw new IllegalStateException("GPX document doesn't have any <trkpt/> elements, which are required.");
}
private static boolean isValidTrkpt(@Nonnull WptType trkpt) {
return trkpt.getLon() != null && trkpt.getLat() != null && trkpt.getTime() != null;
}
private static final Predicate<WptType> IS_VALID_TRKPT = new Predicate<WptType>() {
@Override
public boolean apply(@Nullable WptType trkpt) {
return trkpt != null && isValidTrkpt(trkpt);
}
};
@Nonnull SplineFunctions generateSplines(@Nonnull GpxType gpxDocument) throws ConverterException {
Preconditions.checkNotNull(gpxDocument, "gpxDocument argument is null.");
logger.out(" - generating splines");
List<Long> durations = Lists.newArrayList(0L);
List<Double> distances = Lists.newArrayList(0d);
List<Short> heartRates = new ArrayList<>();
DistanceCalculator distanceCalculator = new GeodesicSphereDistCalc.Vincenty();
long totalPausedTime = 0;
double totalDistance = 0;
long workoutStartTime = 0;
long previousDuration = 0;
Point previousPoint = null;
for (TrkType trks : gpxDocument.getTrk()) {
for (TrksegType trkSeg : trks.getTrkseg()) {
// Each <trkseg /> element represents a period where the device is not paused.
// We need at least 2 <trkpt /> elemnts to calculate anything useful.
if (trkSeg.getTrkpt().size() > 1) {
Iterator<WptType> trkptIt = Iterators.filter(trkSeg.getTrkpt().iterator(), IS_VALID_TRKPT);
// Get the first <trkpt /> in this <trkSeg />
WptType trkpt = trkptIt.next();
// If this is our very first <trkpt /> in our workout...
if (previousPoint == null) {
workoutStartTime = trkpt.getTime().toGregorianCalendar().getTimeInMillis();
previousPoint = getPoint(trkpt);
// If we have heart rate data, add it
Short heartRate = getHeartRate(trkpt);
if (heartRate != null) {
heartRates.add(heartRate);
}
} else {
long duration = getMillisSinceWorkoutStart(trkpt.getTime(), workoutStartTime, totalPausedTime);
long pausedTime = duration - previousDuration;
totalPausedTime += pausedTime;
previousPoint = getPoint(trkpt);
}
// Iterate through the remaining <trkpt />'s in this <trkseg />, adding the duration/distance for each.
while (trkptIt.hasNext()) {
trkpt = trkptIt.next();
long duration = getMillisSinceWorkoutStart(trkpt.getTime(), workoutStartTime, totalPausedTime);
if (duration > previousDuration) {
Point point = getPoint(trkpt);
if (!point.equals(previousPoint)) {
durations.add(duration);
double distanceKm = distanceCalculator.distance(previousPoint, point) * DistanceUtils.DEG_TO_KM;
totalDistance += distanceKm;
distances.add(totalDistance);
// If we have heart rate data, add it
Short heartRate = getHeartRate(trkpt);
if (heartRate != null) {
heartRates.add(heartRate);
}
previousDuration = duration;
previousPoint = point;
}
}
}
}
}
}
// If we less than 3 points we can't interpolate
if (durations.size() > 2) {
SplineInterpolator interpolator = new SplineInterpolator();
PolynomialSplineFunction durationToDistanceFunction = interpolator.interpolate(Doubles.toArray(durations), Doubles.toArray(distances));
PolynomialSplineFunction distanceToDurationFunction = interpolator.interpolate(Doubles.toArray(distances), Doubles.toArray(durations));
PolynomialSplineFunction durationToHeartRateFunction = (heartRates.size() == durations.size())
? interpolator.interpolate(Doubles.toArray(durations), Doubles.toArray(heartRates))
: null;
return new SplineFunctions(durationToDistanceFunction, distanceToDurationFunction, durationToHeartRateFunction);
} else {
throw new ConverterException("Unable to extract GPS data which is required for conversion.");
}
}
private @Nullable Short getHeartRate(@Nonnull WptType trkpt) {
ExtensionsType extensions = trkpt.getExtensions();
if (extensions != null) {
for (Object xsdAny : extensions.getAny()) {
if (xsdAny instanceof JAXBElement<?>) {
Object xsdAnyValue = ((JAXBElement) xsdAny).getValue();
if (xsdAnyValue instanceof TrackPointExtensionT) {
TrackPointExtensionT trackPointExtension = (TrackPointExtensionT) xsdAnyValue;
if (trackPointExtension.getHr() != null) {
return trackPointExtension.getHr();
}
}
}
}
}
return null;
}
private @Nonnull Point getPoint(@Nonnull WptType trkpt) {
return new PointImpl(trkpt.getLon().doubleValue(), trkpt.getLat().doubleValue(), SpatialContext.GEO);
}
private long getMillisSinceWorkoutStart(@Nonnull XMLGregorianCalendar currentTime, long workoutStartTime, long totalTimePaused) {
return currentTime.toGregorianCalendar().getTimeInMillis() - workoutStartTime - totalTimePaused;
}
private static class SplineFunctions {
private final @Nonnull PolynomialSplineFunction durationToDistance;
private final @Nonnull PolynomialSplineFunction distanceToDuration;
private final @Nullable PolynomialSplineFunction durationToHeartRate;
private SplineFunctions(@Nonnull PolynomialSplineFunction durationToDistance, @Nonnull PolynomialSplineFunction distanceToDuration, @Nullable PolynomialSplineFunction durationToHeartRate) {
this.durationToDistance = Preconditions.checkNotNull(durationToDistance, "durationToDistance argument is null.");
this.distanceToDuration = Preconditions.checkNotNull(distanceToDuration, "distanceToDuration argument is null.");
this.durationToHeartRate = durationToHeartRate;
}
}
}