From edf4ccc941d5226beae5b486238507db2e3acc50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A1n=20Mart=C3=ADnez?= Date: Sat, 20 Feb 2021 18:32:47 +0100 Subject: [PATCH] It computes the average for heart rate, cadence, and power (duration-based average) and the maximum for heart rate and cadence. --- .../content/data/TestSensorDataUtil.java | 92 +++++ .../CustomContentProviderUtilsTest.java | 337 ++++++++++++++++++ .../content/data/TrackPointsColumns.java | 7 + .../content/data/TracksColumns.java | 1 + .../provider/ContentProviderUtils.java | 35 +- .../provider/CustomContentProvider.java | 39 ++ .../opentracks/stats/SensorStatistics.java | 4 +- 7 files changed, 490 insertions(+), 25 deletions(-) create mode 100644 src/androidTest/java/de/dennisguse/opentracks/content/data/TestSensorDataUtil.java diff --git a/src/androidTest/java/de/dennisguse/opentracks/content/data/TestSensorDataUtil.java b/src/androidTest/java/de/dennisguse/opentracks/content/data/TestSensorDataUtil.java new file mode 100644 index 000000000..9665237c8 --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/content/data/TestSensorDataUtil.java @@ -0,0 +1,92 @@ +package de.dennisguse.opentracks.content.data; + + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +public class TestSensorDataUtil { + + private List trackPointList = new ArrayList<>(); + private List sensorDataList = new ArrayList<>(); + + public void add(Instant time, Float hr, Float cadence, Float power, TrackPoint.Type type) { + sensorDataList.add(new TestSensorDataUtil.SensorData(time, hr, cadence, power, type)); + TrackPoint tp = new TrackPoint(type); + int i = trackPointList.size() + 1; + tp.setLatitude(TestDataUtil.INITIAL_LATITUDE + (double) i / 10000.0); + tp.setLongitude(TestDataUtil.INITIAL_LONGITUDE - (double) i / 10000.0); + tp.setHeartRate_bpm(hr); + tp.setCyclingCadence_rpm(cadence); + tp.setPower(power); + tp.setAccuracy(1f); + tp.setAltitude(1f); + tp.setTime(time); + tp.setSpeed(5f + (i / 10f)); + tp.setElevationGain(3f); + tp.setElevationLoss(3f); + trackPointList.add(tp); + } + + public List getTrackPointList() { + return this.trackPointList; + } + + public SensorDataStats computeStats() { + if (sensorDataList == null || sensorDataList.size() <= 1) { + return null; + } + + SensorDataStats stats = new SensorDataStats(); + long timeElapsed; + long movingTime = 0; + SensorData dataPrev = sensorDataList.get(0); + stats.maxHr = dataPrev.hr; + stats.maxCadence = dataPrev.cadence; + SensorData dataCurrent; + for (int i = 1; i < sensorDataList.size(); i++) { + dataCurrent = sensorDataList.get(i); + if (dataPrev.type != TrackPoint.Type.SEGMENT_START_MANUAL) { + timeElapsed = dataCurrent.type != TrackPoint.Type.SEGMENT_START_MANUAL ? dataCurrent.time.getEpochSecond() - dataPrev.time.getEpochSecond() : 0; + stats.avgHr += (dataPrev.hr * timeElapsed); + stats.maxHr = dataPrev.hr > stats.maxHr ? dataPrev.hr : stats.maxHr; + stats.avgCadence += (dataPrev.cadence * timeElapsed); + stats.maxCadence = dataPrev.cadence > stats.maxCadence ? dataPrev.cadence : stats.maxCadence; + stats.avgPower += (dataPrev.power * timeElapsed); + + movingTime += timeElapsed; + } + dataPrev = dataCurrent; + } + + stats.avgHr /= movingTime; + stats.avgCadence /= movingTime; + stats.avgPower /= movingTime; + + return stats; + } + + private static class SensorData { + Instant time; + float hr; + float cadence; + float power; + TrackPoint.Type type; + + public SensorData(Instant time, Float hr, Float cadence, Float power, TrackPoint.Type type) { + this.time = time; + this.hr = hr == null ? 0 : hr; + this.cadence = cadence == null ? 0 : cadence; + this.power = power == null ? 0 : power; + this.type = type; + } + } + + public static class SensorDataStats { + public float avgHr = 0f; + public float maxHr = 0f; + public float avgCadence = 0f; + public float maxCadence = 0f; + public float avgPower = 0f; + } +} diff --git a/src/androidTest/java/de/dennisguse/opentracks/content/provider/CustomContentProviderUtilsTest.java b/src/androidTest/java/de/dennisguse/opentracks/content/provider/CustomContentProviderUtilsTest.java index 6543969ec..9e6a1f7c0 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/content/provider/CustomContentProviderUtilsTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/content/provider/CustomContentProviderUtilsTest.java @@ -35,18 +35,22 @@ import java.io.File; import java.io.IOException; import java.time.Duration; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; +import java.util.Random; import java.util.UUID; import java.util.stream.Collectors; import de.dennisguse.opentracks.content.data.Marker; import de.dennisguse.opentracks.content.data.MarkerColumns; import de.dennisguse.opentracks.content.data.TestDataUtil; +import de.dennisguse.opentracks.content.data.TestSensorDataUtil; import de.dennisguse.opentracks.content.data.Track; import de.dennisguse.opentracks.content.data.TrackPoint; import de.dennisguse.opentracks.content.data.TrackPointsColumns; import de.dennisguse.opentracks.content.data.TracksColumns; +import de.dennisguse.opentracks.stats.SensorStatistics; import de.dennisguse.opentracks.stats.TrackStatistics; import de.dennisguse.opentracks.util.FileUtils; import de.dennisguse.opentracks.util.UUIDUtils; @@ -927,4 +931,337 @@ public class CustomContentProviderUtilsTest { assertEquals("12", ContentProviderUtils.formatIdListForUri(new Track.Id(12))); assertEquals("42,43,44", ContentProviderUtils.formatIdListForUri(new Track.Id(42), new Track.Id(43), new Track.Id(44))); } + + @Test + public void testGetSensorStats_noSensorData() { + // given + List trackPointList = new ArrayList<>(); + TrackPoint trackPoint = TestDataUtil.createTrackPoint(1); + trackPoint.setType(TrackPoint.Type.TRACKPOINT); + trackPoint.setPower(null); + trackPoint.setCyclingCadence_rpm(null); + trackPoint.setHeartRate_bpm(null); + trackPointList.add(trackPoint); + Track.Id trackId = new Track.Id(System.currentTimeMillis()); + Track track = TestDataUtil.createTrack(trackId); + TestDataUtil.insertTrackWithLocations(contentProviderUtils, track, trackPointList); + + // when + SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); + + // then + assertFalse(sensorStatistics.hasHeartRate()); + assertFalse(sensorStatistics.hasCadence()); + assertFalse(sensorStatistics.hasPower()); + } + + @Test + public void testGetSensorStats_needAtLeastTwoTrackPointsFalse() { + // given + /* + * time elapsed hr cadence power track type + * 0 140 90 300 0 + */ + Instant start = Instant.now(); + TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil(); + sensorDataUtil.add(start, 140f, 90f, 300f, TrackPoint.Type.TRACKPOINT); + + Track.Id trackId = new Track.Id(start.toEpochMilli()); + Track track = TestDataUtil.createTrack(trackId); + TestDataUtil.insertTrackWithLocations(contentProviderUtils, track, sensorDataUtil.getTrackPointList()); + + // when + SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); + + // then + assertFalse(sensorStatistics.hasHeartRate()); + assertFalse(sensorStatistics.hasCadence()); + assertFalse(sensorStatistics.hasPower()); + } + + @Test + public void testGetSensorStats_needAtLeastTwoTrackPointsTrue() { + // given + /* + * time elapsed hr cadence power track type + * 0 140 90 300 -1 + * 1 140 90 300 1 + */ + Instant start = Instant.now(); + TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil(); + sensorDataUtil.add(start, 140f, 90f, 300f, TrackPoint.Type.SEGMENT_START_AUTOMATIC); + sensorDataUtil.add(start.plus(1, ChronoUnit.SECONDS), 140f, 90f, 300f, TrackPoint.Type.SEGMENT_END_MANUAL); + + Track.Id trackId = new Track.Id(start.toEpochMilli()); + Track track = TestDataUtil.createTrack(trackId); + TestDataUtil.insertTrackWithLocations(contentProviderUtils, track, sensorDataUtil.getTrackPointList()); + + // when + SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); + TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats(); + + // then + assertTrue(sensorStatistics.hasHeartRate()); + assertEquals(sensorStatistics.getAvgHeartRate(), stats.avgHr, 0f); + assertEquals(sensorStatistics.getMaxHeartRate(), stats.maxHr, 0f); + assertTrue(sensorStatistics.hasCadence()); + assertEquals(sensorStatistics.getAvgCadence(), stats.avgCadence, 0f); + assertEquals(sensorStatistics.getMaxCadence(), stats.maxCadence, 0f); + assertTrue(sensorStatistics.hasPower()); + assertEquals(sensorStatistics.getAvgPower(), stats.avgPower, 0f); + } + + @Test + public void testGetSensorStats_onlyHr() { + // given + /* + * time elapsed hr cadence power track type + * 0 140 NULL NULL -1 + * 1 140 NULL NULL 1 + */ + Instant start = Instant.now(); + TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil(); + sensorDataUtil.add(start, 140f, null, null, TrackPoint.Type.SEGMENT_START_AUTOMATIC); + sensorDataUtil.add(start.plus(1, ChronoUnit.SECONDS), 140f, null, null, TrackPoint.Type.SEGMENT_END_MANUAL); + + Track.Id trackId = new Track.Id(start.toEpochMilli()); + Track track = TestDataUtil.createTrack(trackId); + TestDataUtil.insertTrackWithLocations(contentProviderUtils, track, sensorDataUtil.getTrackPointList()); + + // when + SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); + TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats(); + + // then + assertTrue(sensorStatistics.hasHeartRate()); + assertEquals(sensorStatistics.getAvgHeartRate(), stats.avgHr, 0f); + assertEquals(sensorStatistics.getMaxHeartRate(), stats.maxHr, 0f); + assertFalse(sensorStatistics.hasCadence()); + assertFalse(sensorStatistics.hasPower()); + } + + @Test + public void testGetSensorStats_onlyCadence() { + // given + /* + * time elapsed hr cadence power track type + * 0 NULL 90 NULL -1 + * 1 NULL 90 NULL 1 + */ + Instant start = Instant.now(); + TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil(); + sensorDataUtil.add(start, null, 90f, null, TrackPoint.Type.SEGMENT_START_AUTOMATIC); + sensorDataUtil.add(start.plus(1, ChronoUnit.SECONDS), null, 90f, null, TrackPoint.Type.SEGMENT_END_MANUAL); + + Track.Id trackId = new Track.Id(start.toEpochMilli()); + Track track = TestDataUtil.createTrack(trackId); + TestDataUtil.insertTrackWithLocations(contentProviderUtils, track, sensorDataUtil.getTrackPointList()); + + // when + SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); + TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats(); + + // then + assertFalse(sensorStatistics.hasHeartRate()); + assertTrue(sensorStatistics.hasCadence()); + assertEquals(sensorStatistics.getAvgCadence(), stats.avgCadence, 0f); + assertEquals(sensorStatistics.getMaxCadence(), stats.maxCadence, 0f); + assertFalse(sensorStatistics.hasPower()); + } + + @Test + public void testGetSensorStats_onlyPower() { + // given + /* + * time elapsed hr cadence power track type + * 0 NULL NULL 300 -1 + * 1 NULL NULL 300 1 + */ + Instant start = Instant.now(); + TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil(); + sensorDataUtil.add(start, null, null, 300f, TrackPoint.Type.SEGMENT_START_AUTOMATIC); + sensorDataUtil.add(start.plus(1, ChronoUnit.SECONDS), null, null, 300f, TrackPoint.Type.SEGMENT_END_MANUAL); + + Track.Id trackId = new Track.Id(start.toEpochMilli()); + Track track = TestDataUtil.createTrack(trackId); + TestDataUtil.insertTrackWithLocations(contentProviderUtils, track, sensorDataUtil.getTrackPointList()); + + // when + SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); + TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats(); + + // then + assertFalse(sensorStatistics.hasHeartRate()); + assertFalse(sensorStatistics.hasCadence()); + assertTrue(sensorStatistics.hasPower()); + assertEquals(sensorStatistics.getAvgPower(), stats.avgPower, 0f); + } + + @Test + public void testGetSensorStats() { + // given + /* + * time elapsed hr cadence power track type + * 0 140 75 250 -1 + * 2 148 80 300 0 + * 1 150 82 325 0 + * 7 160 90 275 0 + * 4 155 85 280 0 + * 1 155 84 295 1 + */ + Instant start = Instant.now(); + TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil(); + sensorDataUtil.add(start, 140f, 75f, 250f, TrackPoint.Type.SEGMENT_START_AUTOMATIC); + sensorDataUtil.add(start.plus(2, ChronoUnit.SECONDS), 148f, 80f, 300f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(3, ChronoUnit.SECONDS), 150f, 82f, 325f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(10, ChronoUnit.SECONDS), 160f, 90f, 275f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(14, ChronoUnit.SECONDS), 155f, 85f, 280f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(15, ChronoUnit.SECONDS), 155f, 84f, 295f, TrackPoint.Type.SEGMENT_END_MANUAL); + + Track.Id trackId = new Track.Id(start.toEpochMilli()); + Track track = TestDataUtil.createTrack(trackId); + TestDataUtil.insertTrackWithLocations(contentProviderUtils, track, sensorDataUtil.getTrackPointList()); + + // when + SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); + TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats(); + + // then + assertEquals(sensorStatistics.getAvgHeartRate(), stats.avgHr, 0f); + assertEquals(sensorStatistics.getMaxHeartRate(), stats.maxHr, 0f); + assertEquals(sensorStatistics.getAvgCadence(), stats.avgCadence, 0f); + assertEquals(sensorStatistics.getMaxCadence(), stats.maxCadence, 0f); + assertEquals(sensorStatistics.getAvgPower(), stats.avgPower, 0f); + } + + @Test + public void testGetSensorStats_withManualResume() { + // given + /* + * time elapsed hr cadence power track type + * 0 140 75 250 -1 + * 2 148 80 300 0 + * 1 150 82 325 0 + * 3 174 88 400 0 + * 20 127 54 175 -2 + * 3 160 90 275 0 + * 7 155 85 280 0 + * 3 150 90 267 0 + * 3 170 90 240 0 + * 2 155 84 295 1 + */ + Instant start = Instant.now(); + TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil(); + sensorDataUtil.add(start, 140f, 75f, 250f, TrackPoint.Type.SEGMENT_START_AUTOMATIC); + sensorDataUtil.add(start.plus(2, ChronoUnit.SECONDS), 148f, 80f, 300f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(3, ChronoUnit.SECONDS), 150f, 82f, 325f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(6, ChronoUnit.SECONDS), 174f, 88f, 400f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(26, ChronoUnit.SECONDS), 127f, 54f, 175f, TrackPoint.Type.SEGMENT_START_MANUAL); + sensorDataUtil.add(start.plus(29, ChronoUnit.SECONDS), 160f, 90f, 275f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(36, ChronoUnit.SECONDS), 155f, 85f, 280f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(39, ChronoUnit.SECONDS), 150f, 90f, 267f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(42, ChronoUnit.SECONDS), 170f, 90f, 240f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(44, ChronoUnit.SECONDS), 155f, 84f, 295f, TrackPoint.Type.SEGMENT_END_MANUAL); + + Track.Id trackId = new Track.Id(start.toEpochMilli()); + Track track = TestDataUtil.createTrack(trackId); + TestDataUtil.insertTrackWithLocations(contentProviderUtils, track, sensorDataUtil.getTrackPointList()); + + // when + SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); + TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats(); + + // then + assertEquals(sensorStatistics.getAvgHeartRate(), stats.avgHr, 0f); + assertEquals(sensorStatistics.getMaxHeartRate(), stats.maxHr, 0f); + assertEquals(sensorStatistics.getAvgCadence(), stats.avgCadence, 0f); + assertEquals(sensorStatistics.getMaxCadence(), stats.maxCadence, 0f); + assertEquals(sensorStatistics.getAvgPower(), stats.avgPower, 0f); + } + + @Test + public void testGetSensorStats_withStartAutomatic() { + // given + /* + * time elapsed hr cadence power track type + * 0 140 75 250 -1 + * 2 148 80 300 0 + * 1 150 82 325 0 + * 3 174 88 400 0 + * 20 127 54 175 -1 + * 3 160 90 275 0 + * 7 155 85 280 0 + * 3 150 90 267 0 + * 3 170 90 240 0 + * 2 155 84 295 1 + */ + Instant start = Instant.now(); + TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil(); + sensorDataUtil.add(start, 140f, 75f, 250f, TrackPoint.Type.SEGMENT_START_AUTOMATIC); + sensorDataUtil.add(start.plus(2, ChronoUnit.SECONDS), 148f, 80f, 300f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(3, ChronoUnit.SECONDS), 150f, 82f, 325f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(6, ChronoUnit.SECONDS), 174f, 88f, 400f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(26, ChronoUnit.SECONDS), 127f, 54f, 175f, TrackPoint.Type.SEGMENT_START_AUTOMATIC); + sensorDataUtil.add(start.plus(29, ChronoUnit.SECONDS), 160f, 90f, 275f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(36, ChronoUnit.SECONDS), 155f, 85f, 280f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(39, ChronoUnit.SECONDS), 150f, 90f, 267f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(42, ChronoUnit.SECONDS), 170f, 90f, 240f, TrackPoint.Type.TRACKPOINT); + sensorDataUtil.add(start.plus(44, ChronoUnit.SECONDS), 155f, 84f, 295f, TrackPoint.Type.SEGMENT_END_MANUAL); + + Track.Id trackId = new Track.Id(start.toEpochMilli()); + Track track = TestDataUtil.createTrack(trackId); + TestDataUtil.insertTrackWithLocations(contentProviderUtils, track, sensorDataUtil.getTrackPointList()); + + // when + SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); + TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats(); + + // then + assertEquals(sensorStatistics.getAvgHeartRate(), stats.avgHr, 0f); + assertEquals(sensorStatistics.getMaxHeartRate(), stats.maxHr, 0f); + assertEquals(sensorStatistics.getAvgCadence(), stats.avgCadence, 0f); + assertEquals(sensorStatistics.getMaxCadence(), stats.maxCadence, 0f); + assertEquals(sensorStatistics.getAvgPower(), stats.avgPower, 0f); + } + + private void testGetSensorStats_randomData(int totalPoints, boolean withStartSegments) { + // given + Instant start = Instant.now(); + TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil(); + Random random = new Random(); + for (int i = 0; i < totalPoints; i++) { + int randomNum = withStartSegments ? random.nextInt(50) - 2 : 0; + TrackPoint.Type type = randomNum >= 0 ? TrackPoint.Type.TRACKPOINT : TrackPoint.Type.getById(randomNum); + float randomHr = random.nextFloat() * (200f - 90f) + 90f; + float randomCadence = random.nextFloat() * (110f - 40f) + 40f; + float randomPower = random.nextFloat() * (500f - 100f) + 100f; + sensorDataUtil.add(start.plus(i, ChronoUnit.SECONDS), randomHr, randomCadence, randomPower, type); + } + sensorDataUtil.add(start.plus(totalPoints, ChronoUnit.SECONDS), 155f, 84f, 295f, TrackPoint.Type.SEGMENT_END_MANUAL); + + Track.Id trackId = new Track.Id(start.toEpochMilli()); + Track track = TestDataUtil.createTrack(trackId); + TestDataUtil.insertTrackWithLocations(contentProviderUtils, track, sensorDataUtil.getTrackPointList()); + + // when + SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); + TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats(); + + // then + assertEquals(sensorStatistics.getAvgHeartRate(), stats.avgHr, 0.01f); + assertEquals(sensorStatistics.getMaxHeartRate(), stats.maxHr, 0.01f); + assertEquals(sensorStatistics.getAvgCadence(), stats.avgCadence, 0.01f); + assertEquals(sensorStatistics.getMaxCadence(), stats.maxCadence, 0.01f); + assertEquals(sensorStatistics.getAvgPower(), stats.avgPower, 0.01f); + } + + @Test + public void testGetSensorStats_veryLongActivity12h() { + testGetSensorStats_randomData(43200, false); + } + + @Test + public void testGetSensorStats_withSeveralRandomStartSegments() { + testGetSensorStats_randomData(5000, true); + } } diff --git a/src/main/java/de/dennisguse/opentracks/content/data/TrackPointsColumns.java b/src/main/java/de/dennisguse/opentracks/content/data/TrackPointsColumns.java index 7c511a04e..6bf61e236 100644 --- a/src/main/java/de/dennisguse/opentracks/content/data/TrackPointsColumns.java +++ b/src/main/java/de/dennisguse/opentracks/content/data/TrackPointsColumns.java @@ -54,6 +54,13 @@ public interface TrackPointsColumns extends BaseColumns { String ELEVATION_GAIN = "elevation_gain"; String ELEVATION_LOSS = "elevation_loss"; + // Alias for sensor statistics + String ALIAS_AVG_HR = "avg_hr"; + String ALIAS_MAX_HR = "max_hr"; + String ALIAS_AVG_CADENCE = "avg_cadence"; + String ALIAS_MAX_CADENCE = "max_cadence"; + String ALIAS_AVG_POWER = "avg_power"; + String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + _ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + TRACKID + " INTEGER NOT NULL, " diff --git a/src/main/java/de/dennisguse/opentracks/content/data/TracksColumns.java b/src/main/java/de/dennisguse/opentracks/content/data/TracksColumns.java index 95220beaf..27dcc4e30 100644 --- a/src/main/java/de/dennisguse/opentracks/content/data/TracksColumns.java +++ b/src/main/java/de/dennisguse/opentracks/content/data/TracksColumns.java @@ -30,6 +30,7 @@ public interface TracksColumns extends BaseColumns { String TABLE_NAME = "tracks"; Uri CONTENT_URI = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME); + Uri CONTENT_URI_SENSOR_STATS = Uri.parse(ContentProviderUtils.CONTENT_BASE_URI + "/" + TABLE_NAME + "/sensorstats"); String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.de.dennisguse.track"; String CONTENT_ITEMTYPE = "vnd.android.cursor.item/vnd.de.dennisguse.track"; String DEFAULT_SORT_ORDER = _ID; diff --git a/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java b/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java index c75488ded..61907f404 100644 --- a/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java +++ b/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java @@ -17,6 +17,7 @@ package de.dennisguse.opentracks.content.provider; import android.content.ContentResolver; +import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; @@ -837,31 +838,19 @@ public class ContentProviderUtils { return TextUtils.split(url.getLastPathSegment(), ID_SEPARATOR); } - public SensorStatistics getSensorStats(Track.Id trackId) { - if (trackId == null) { - return null; - } - String[] projection = { - "MAX(" + TrackPointsColumns.SENSOR_HEARTRATE + ") max_hr", - "AVG(" + TrackPointsColumns.SENSOR_HEARTRATE + ") avg_hr", - "MAX(" + TrackPointsColumns.SENSOR_CADENCE + ") max_cadence", - "AVG(" + TrackPointsColumns.SENSOR_CADENCE + ") avg_cadence", - "AVG(" + TrackPointsColumns.SENSOR_POWER + ") avg_power" - }; - String selection = TrackPointsColumns.TRACKID + "=?"; - String[] selectionArgs = new String[]{Long.toString(trackId.getId())}; + public SensorStatistics getSensorStats(@NonNull Track.Id trackId) { SensorStatistics sensorStatistics = null; - try (Cursor cursor = getTrackPointCursor(projection, selection, selectionArgs, null)) { - if (cursor != null && cursor.moveToFirst()) { - sensorStatistics = new SensorStatistics( - !cursor.isNull(cursor.getColumnIndexOrThrow("max_hr")) ? cursor.getFloat(cursor.getColumnIndexOrThrow("max_hr")) : null, - !cursor.isNull(cursor.getColumnIndexOrThrow("avg_hr")) ? cursor.getFloat(cursor.getColumnIndexOrThrow("avg_hr")) : null, - !cursor.isNull(cursor.getColumnIndexOrThrow("max_cadence")) ? cursor.getFloat(cursor.getColumnIndexOrThrow("max_cadence")) : null, - !cursor.isNull(cursor.getColumnIndexOrThrow("avg_cadence")) ? cursor.getFloat(cursor.getColumnIndexOrThrow("avg_cadence")) : null, - !cursor.isNull(cursor.getColumnIndexOrThrow("avg_power")) ? cursor.getFloat(cursor.getColumnIndexOrThrow("avg_power")) : null - ); - } + Cursor cursor = contentResolver.query(ContentUris.withAppendedId(TracksColumns.CONTENT_URI_SENSOR_STATS, trackId.getId()), null, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + sensorStatistics = new SensorStatistics( + !cursor.isNull(cursor.getColumnIndexOrThrow("max_hr")) ? cursor.getFloat(cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_MAX_HR)) : null, + !cursor.isNull(cursor.getColumnIndexOrThrow("avg_hr")) ? cursor.getFloat(cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_AVG_HR)) : null, + !cursor.isNull(cursor.getColumnIndexOrThrow("max_cadence")) ? cursor.getFloat(cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_MAX_CADENCE)) : null, + !cursor.isNull(cursor.getColumnIndexOrThrow("avg_cadence")) ? cursor.getFloat(cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_AVG_CADENCE)) : null, + !cursor.isNull(cursor.getColumnIndexOrThrow("avg_power")) ? cursor.getFloat(cursor.getColumnIndexOrThrow(TrackPointsColumns.ALIAS_AVG_POWER)) : null + ); } + return sensorStatistics; } } diff --git a/src/main/java/de/dennisguse/opentracks/content/provider/CustomContentProvider.java b/src/main/java/de/dennisguse/opentracks/content/provider/CustomContentProvider.java index 60881d61c..13218a4c0 100644 --- a/src/main/java/de/dennisguse/opentracks/content/provider/CustomContentProvider.java +++ b/src/main/java/de/dennisguse/opentracks/content/provider/CustomContentProvider.java @@ -36,6 +36,7 @@ import androidx.annotation.VisibleForTesting; import java.util.Arrays; import de.dennisguse.opentracks.content.data.MarkerColumns; +import de.dennisguse.opentracks.content.data.TrackPoint; import de.dennisguse.opentracks.content.data.TrackPointsColumns; import de.dennisguse.opentracks.content.data.TracksColumns; @@ -56,6 +57,38 @@ public class CustomContentProvider extends ContentProvider { private SQLiteDatabase db; + /** + * The string representing the query that compute sensor stats from trackpoints table. + * It computes the average for heart rate, cadence, and power (duration-based average) and the maximum for heart rate and cadence. + * Finally, it ignores manual pause (SEGMENT_START_MANUAL). + */ + private final String SENSOR_STATS_QUERY = + "WITH time_select as " + + "(SELECT t1." + TrackPointsColumns.TIME + " * (t1." + TrackPointsColumns.TYPE + " NOT IN (" + TrackPoint.Type.SEGMENT_START_MANUAL.type_db + ")) time_value " + + "FROM " + TrackPointsColumns.TABLE_NAME + " t1 " + + "WHERE t1." + TrackPointsColumns._ID + " > t." + TrackPointsColumns._ID + " AND t1." + TrackPointsColumns.TRACKID + " = ? ORDER BY _id LIMIT 1) " + + + "SELECT " + + "SUM(t." + TrackPointsColumns.SENSOR_HEARTRATE + " * (COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ")) " + + "/ " + + "SUM(COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ") " + TrackPointsColumns.ALIAS_AVG_HR + ", " + + + "MAX(t." + TrackPointsColumns.SENSOR_HEARTRATE + ") " + TrackPointsColumns.ALIAS_MAX_HR + ", " + + + "SUM(t." + TrackPointsColumns.SENSOR_CADENCE + " * (COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ")) " + + "/ " + + "SUM(COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ") " + TrackPointsColumns.ALIAS_AVG_CADENCE + ", " + + + "MAX(t." + TrackPointsColumns.SENSOR_CADENCE + ") " + TrackPointsColumns.ALIAS_MAX_CADENCE + ", " + + + "SUM(t." + TrackPointsColumns.SENSOR_POWER + " * (COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ")) " + + "/ " + + "SUM(COALESCE(MAX(t." + TrackPointsColumns.TIME + ", (SELECT time_value FROM time_select)), t." + TrackPointsColumns.TIME + ") - t." + TrackPointsColumns.TIME + ") " + TrackPointsColumns.ALIAS_AVG_POWER + " " + + + "FROM " + TrackPointsColumns.TABLE_NAME + " t " + + "WHERE t." + TrackPointsColumns.TRACKID + " = ? " + + "AND t." + TrackPointsColumns.TYPE + " NOT IN (" + TrackPoint.Type.SEGMENT_START_MANUAL.type_db + ")"; + public CustomContentProvider() { uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TrackPointsColumns.CONTENT_URI_BY_ID.getPath(), UrlType.TRACKPOINTS.ordinal()); @@ -63,6 +96,7 @@ public class CustomContentProvider extends ContentProvider { uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TrackPointsColumns.CONTENT_URI_BY_TRACKID.getPath() + "/*", UrlType.TRACKPOINTS_BY_TRACKID.ordinal()); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI.getPath(), UrlType.TRACKS.ordinal()); + uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI_SENSOR_STATS.getPath() + "/#", UrlType.TRACKS_SENSOR_STATS.ordinal()); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, TracksColumns.CONTENT_URI.getPath() + "/*", UrlType.TRACKS_BY_ID.ordinal()); uriMatcher.addURI(ContentProviderUtils.AUTHORITY_PACKAGE, MarkerColumns.CONTENT_URI.getPath(), UrlType.MARKERS.ordinal()); @@ -223,6 +257,10 @@ public class CustomContentProvider extends ContentProvider { queryBuilder.setTables(TracksColumns.TABLE_NAME); queryBuilder.appendWhere(TracksColumns._ID + " IN (" + TextUtils.join(SQL_LIST_DELIMITER, ContentProviderUtils.parseTrackIdsFromUri(url)) + ")"); break; + case TRACKS_SENSOR_STATS: + long trackId = ContentUris.parseId(url); + Cursor cursor = db.rawQuery(SENSOR_STATS_QUERY, new String[]{String.valueOf(trackId), String.valueOf(trackId)}); + return cursor; case MARKERS: queryBuilder.setTables(MarkerColumns.TABLE_NAME); sortOrder = sort != null ? sort : MarkerColumns.DEFAULT_SORT_ORDER; @@ -363,6 +401,7 @@ public class CustomContentProvider extends ContentProvider { TRACKPOINTS_BY_TRACKID, TRACKS, TRACKS_BY_ID, + TRACKS_SENSOR_STATS, MARKERS, MARKERS_BY_ID, MARKERS_BY_TRACKID diff --git a/src/main/java/de/dennisguse/opentracks/stats/SensorStatistics.java b/src/main/java/de/dennisguse/opentracks/stats/SensorStatistics.java index 44cff4860..495bc2668 100644 --- a/src/main/java/de/dennisguse/opentracks/stats/SensorStatistics.java +++ b/src/main/java/de/dennisguse/opentracks/stats/SensorStatistics.java @@ -16,7 +16,7 @@ public class SensorStatistics { } public boolean hasHeartRate() { - return maxHr != null; + return avgHr != null && maxHr != null; } public float getMaxHeartRate() { @@ -28,7 +28,7 @@ public class SensorStatistics { } public boolean hasCadence() { - return maxCadence != null; + return avgCadence != null && maxCadence != null; } public float getMaxCadence() {