mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3746 from vitorpamplona/claude/health-connect-activity-consolidation-y8ao9e
Merge close workout sessions to reduce noise in Health Connect suggestions
This commit is contained in:
+9
-1
@@ -30,7 +30,9 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
* UI directly.
|
||||
*
|
||||
* [id] is the Health Connect record id, used to remember which sessions the
|
||||
* user has already handled (accepted or dismissed) so each is offered once.
|
||||
* user has already handled (accepted or dismissed) so each is offered once. When
|
||||
* several close-by sessions of the same type are combined by [WorkoutMerger],
|
||||
* [id] becomes the members' ids joined with `+` and [sessionCount] rises above 1.
|
||||
*/
|
||||
@Immutable
|
||||
data class DetectedWorkout(
|
||||
@@ -47,4 +49,10 @@ data class DetectedWorkout(
|
||||
val elevationGainMeters: Double?,
|
||||
/** Human-readable name of the app/device that wrote the record (e.g. "Samsung Health"). */
|
||||
val source: String,
|
||||
/**
|
||||
* How many Health Connect sessions this workout represents. 1 for a raw
|
||||
* session; higher when [WorkoutMerger] combined several close-by same-type
|
||||
* sessions (e.g. a long run split around breaks) into a single suggestion.
|
||||
*/
|
||||
val sessionCount: Int = 1,
|
||||
)
|
||||
|
||||
+5
-2
@@ -142,8 +142,11 @@ class HealthConnectManager(
|
||||
)
|
||||
Log.i(TAG) { "readNewWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" }
|
||||
val mapped = response.records.mapNotNull { mapSession(it) }
|
||||
Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} workout(s) after type/duration filtering" }
|
||||
mapped
|
||||
// Fold split-up sessions of the same activity (a long run broken around
|
||||
// breaks) into one suggestion so the composer offers the whole effort.
|
||||
val merged = WorkoutMerger.mergeCloseWorkouts(mapped)
|
||||
Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} -> ${merged.size} workout(s) after type/duration filtering and merging" }
|
||||
merged
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(TAG, "Failed to read workouts from Health Connect", e)
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.workouts.health
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Combines Health Connect exercise sessions that belong to the same real-world
|
||||
* effort. Watches, Strava, and auto-pause features often split one long workout —
|
||||
* a 5-hour run with coffee/water breaks — into several back-to-back
|
||||
* `ExerciseSessionRecord`s of the same type. Posting each break as its own
|
||||
* workout is noise; the user wants the whole run as one thing.
|
||||
*
|
||||
* [mergeCloseWorkouts] walks the sessions in start-time order and joins any run
|
||||
* of same-type sessions whose consecutive gap (start of the next minus the end of
|
||||
* the previous) is under [DEFAULT_MAX_GAP_SECONDS] into a single [DetectedWorkout]:
|
||||
* distance, calories, steps, elevation and duration are summed, heart rate is
|
||||
* duration-weighted, max heart rate is the max, and [DetectedWorkout.sessionCount]
|
||||
* records how many sessions were folded in.
|
||||
*
|
||||
* Sessions of a different type occurring between two same-type sessions do not
|
||||
* break the chain — a brief cross-training block mid-run still lets the two run
|
||||
* segments merge — because gaps are tracked per exercise type.
|
||||
*/
|
||||
object WorkoutMerger {
|
||||
/** Sessions less than one hour apart are treated as one workout. */
|
||||
const val DEFAULT_MAX_GAP_SECONDS = 3600L
|
||||
|
||||
/**
|
||||
* Merges close-by same-type sessions in [workouts]. Input order is irrelevant
|
||||
* (sessions are sorted by start time internally). The result is ordered by
|
||||
* each combined workout's start time, ascending. A gap of exactly
|
||||
* [maxGapSeconds] does NOT merge — only strictly closer sessions do.
|
||||
*/
|
||||
fun mergeCloseWorkouts(
|
||||
workouts: List<DetectedWorkout>,
|
||||
maxGapSeconds: Long = DEFAULT_MAX_GAP_SECONDS,
|
||||
): List<DetectedWorkout> {
|
||||
if (workouts.size < 2) return workouts
|
||||
|
||||
val sorted = workouts.sortedBy { it.startTimeEpochSeconds }
|
||||
|
||||
val groups = mutableListOf<MutableList<DetectedWorkout>>()
|
||||
// Most recent still-open group per exercise type, plus the latest end time
|
||||
// seen for that type, so an interleaved activity of a different type never
|
||||
// splits a run of same-type sessions.
|
||||
val openGroupByType = HashMap<ExerciseType, MutableList<DetectedWorkout>>()
|
||||
val lastEndByType = HashMap<ExerciseType, Long>()
|
||||
|
||||
for (workout in sorted) {
|
||||
val openGroup = openGroupByType[workout.exercise]
|
||||
val lastEnd = lastEndByType[workout.exercise]
|
||||
val closeEnough = lastEnd != null && workout.startTimeEpochSeconds - lastEnd < maxGapSeconds
|
||||
|
||||
if (openGroup != null && closeEnough) {
|
||||
openGroup.add(workout)
|
||||
} else {
|
||||
val newGroup = mutableListOf(workout)
|
||||
groups.add(newGroup)
|
||||
openGroupByType[workout.exercise] = newGroup
|
||||
}
|
||||
lastEndByType[workout.exercise] = maxOf(lastEnd ?: Long.MIN_VALUE, endOf(workout))
|
||||
}
|
||||
|
||||
return groups.map { combine(it) }
|
||||
}
|
||||
|
||||
/** End of a raw session: its start plus its (contiguous) duration. */
|
||||
private fun endOf(workout: DetectedWorkout): Long = workout.startTimeEpochSeconds + workout.durationSeconds
|
||||
|
||||
/** Folds a group (already sorted by start time) into one [DetectedWorkout]. */
|
||||
private fun combine(group: List<DetectedWorkout>): DetectedWorkout {
|
||||
if (group.size == 1) return group.first()
|
||||
|
||||
val earliest = group.first()
|
||||
|
||||
val distance = group.mapNotNull { it.distanceMeters }.takeIf { it.isNotEmpty() }?.sum()
|
||||
val calories = group.mapNotNull { it.calories }.takeIf { it.isNotEmpty() }?.sum()
|
||||
val steps = group.mapNotNull { it.steps }.takeIf { it.isNotEmpty() }?.sum()
|
||||
val elevation = group.mapNotNull { it.elevationGainMeters }.takeIf { it.isNotEmpty() }?.sum()
|
||||
val maxHeartRate = group.mapNotNull { it.maxHeartRate }.maxOrNull()
|
||||
|
||||
return DetectedWorkout(
|
||||
id = group.joinToString("+") { it.id },
|
||||
exercise = earliest.exercise,
|
||||
title = group.firstNotNullOfOrNull { it.title?.takeIf(String::isNotBlank) },
|
||||
startTimeEpochSeconds = earliest.startTimeEpochSeconds,
|
||||
durationSeconds = group.sumOf { it.durationSeconds },
|
||||
distanceMeters = distance,
|
||||
calories = calories,
|
||||
avgHeartRate = weightedAvgHeartRate(group),
|
||||
maxHeartRate = maxHeartRate,
|
||||
steps = steps,
|
||||
elevationGainMeters = elevation,
|
||||
source = earliest.source,
|
||||
sessionCount = group.sumOf { it.sessionCount },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration-weighted average heart rate across the members that reported one,
|
||||
* so a 4-hour leg dominates a 5-minute leg. Null when no member has a heart
|
||||
* rate; falls back to a plain average if every contributing leg has zero
|
||||
* duration (shouldn't happen for real sessions).
|
||||
*/
|
||||
private fun weightedAvgHeartRate(group: List<DetectedWorkout>): Int? {
|
||||
val withHeartRate = group.filter { it.avgHeartRate != null }
|
||||
if (withHeartRate.isEmpty()) return null
|
||||
|
||||
val weightSum = withHeartRate.sumOf { it.durationSeconds }
|
||||
return if (weightSum > 0) {
|
||||
withHeartRate
|
||||
.sumOf { it.avgHeartRate!!.toDouble() * it.durationSeconds }
|
||||
.div(weightSum)
|
||||
.roundToInt()
|
||||
} else {
|
||||
withHeartRate.map { it.avgHeartRate!! }.average().roundToInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -210,6 +210,9 @@ private fun summaryLine(workout: DetectedWorkout): String {
|
||||
parts.add(stringRes(R.string.workout_suggestion_distance_km, "%.2f".format(it / 1000.0)))
|
||||
}
|
||||
parts.add(formatWorkoutDuration(workout.durationSeconds))
|
||||
if (workout.sessionCount > 1) {
|
||||
parts.add(stringRes(R.string.workout_suggestion_combined_sessions, workout.sessionCount))
|
||||
}
|
||||
return parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
|
||||
@@ -792,6 +792,7 @@
|
||||
<string name="workout_suggestion_connect_button">Connect</string>
|
||||
<string name="workout_suggestion_distance_km">%1$s km</string>
|
||||
<string name="workout_from_health_connect">From Health Connect</string>
|
||||
<string name="workout_suggestion_combined_sessions">%1$d activities</string>
|
||||
<string name="exercise_running">Running</string>
|
||||
<string name="exercise_walking">Walking</string>
|
||||
<string name="exercise_cycling">Cycling</string>
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.workouts.health
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WorkoutMergerTest {
|
||||
private val hour = 3600L
|
||||
|
||||
private fun workout(
|
||||
id: String,
|
||||
exercise: ExerciseType = ExerciseType.RUNNING,
|
||||
startTimeEpochSeconds: Long,
|
||||
durationSeconds: Long,
|
||||
distanceMeters: Double? = null,
|
||||
calories: Int? = null,
|
||||
avgHeartRate: Int? = null,
|
||||
maxHeartRate: Int? = null,
|
||||
steps: Int? = null,
|
||||
elevationGainMeters: Double? = null,
|
||||
title: String? = null,
|
||||
source: String = "Samsung Health",
|
||||
) = DetectedWorkout(
|
||||
id = id,
|
||||
exercise = exercise,
|
||||
title = title,
|
||||
startTimeEpochSeconds = startTimeEpochSeconds,
|
||||
durationSeconds = durationSeconds,
|
||||
distanceMeters = distanceMeters,
|
||||
calories = calories,
|
||||
avgHeartRate = avgHeartRate,
|
||||
maxHeartRate = maxHeartRate,
|
||||
steps = steps,
|
||||
elevationGainMeters = elevationGainMeters,
|
||||
source = source,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun emptyListPassesThrough() {
|
||||
assertTrue(WorkoutMerger.mergeCloseWorkouts(emptyList()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleWorkoutIsReturnedUnchanged() {
|
||||
val one = workout("a", startTimeEpochSeconds = 0, durationSeconds = 100)
|
||||
val result = WorkoutMerger.mergeCloseWorkouts(listOf(one))
|
||||
assertEquals(1, result.size)
|
||||
assertSame(one, result.first())
|
||||
assertEquals(1, result.first().sessionCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun twoCloseSameTypeSessionsMergeAndSumMetrics() {
|
||||
// Run 10:00-11:00, then Run 11:30-12:00 (30 min gap < 1h): one 90-min run.
|
||||
val first =
|
||||
workout(
|
||||
id = "first",
|
||||
startTimeEpochSeconds = 0,
|
||||
durationSeconds = hour,
|
||||
distanceMeters = 10_000.0,
|
||||
calories = 600,
|
||||
steps = 12_000,
|
||||
elevationGainMeters = 50.0,
|
||||
)
|
||||
val second =
|
||||
workout(
|
||||
id = "second",
|
||||
startTimeEpochSeconds = hour + 1800, // starts 30 min after first ends
|
||||
durationSeconds = 1800,
|
||||
distanceMeters = 5_000.0,
|
||||
calories = 300,
|
||||
steps = 6_000,
|
||||
elevationGainMeters = 25.0,
|
||||
)
|
||||
|
||||
val result = WorkoutMerger.mergeCloseWorkouts(listOf(first, second))
|
||||
|
||||
assertEquals(1, result.size)
|
||||
val merged = result.first()
|
||||
assertEquals("first+second", merged.id)
|
||||
assertEquals(2, merged.sessionCount)
|
||||
assertEquals(0, merged.startTimeEpochSeconds) // earliest start
|
||||
assertEquals(hour + 1800, merged.durationSeconds) // summed active duration
|
||||
assertEquals(15_000.0, merged.distanceMeters!!, 0.0001)
|
||||
assertEquals(900, merged.calories)
|
||||
assertEquals(18_000, merged.steps)
|
||||
assertEquals(75.0, merged.elevationGainMeters!!, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameTypeButFarApartDoesNotMerge() {
|
||||
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour)
|
||||
// Starts 90 min after the first one ends -> gap exceeds the 1h threshold.
|
||||
val second = workout("second", startTimeEpochSeconds = hour + hour + 1800, durationSeconds = hour)
|
||||
|
||||
val result = WorkoutMerger.mergeCloseWorkouts(listOf(first, second))
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals(listOf("first", "second"), result.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gapOfExactlyOneHourDoesNotMerge() {
|
||||
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour)
|
||||
// Starts exactly 1h after the first ends -> boundary is exclusive.
|
||||
val second = workout("second", startTimeEpochSeconds = hour + hour, durationSeconds = hour)
|
||||
|
||||
val result = WorkoutMerger.mergeCloseWorkouts(listOf(first, second))
|
||||
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentTypesCloseTogetherDoNotMerge() {
|
||||
val run = workout("run", exercise = ExerciseType.RUNNING, startTimeEpochSeconds = 0, durationSeconds = hour)
|
||||
val ride = workout("ride", exercise = ExerciseType.CYCLING, startTimeEpochSeconds = hour + 60, durationSeconds = hour)
|
||||
|
||||
val result = WorkoutMerger.mergeCloseWorkouts(listOf(run, ride))
|
||||
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interleavedOtherTypeDoesNotBreakSameTypeChain() {
|
||||
// Run, then a short walk during a break, then Run again — the two runs
|
||||
// are close in time and should still combine across the walk.
|
||||
val run1 = workout("run1", exercise = ExerciseType.RUNNING, startTimeEpochSeconds = 0, durationSeconds = hour)
|
||||
val walk = workout("walk", exercise = ExerciseType.WALKING, startTimeEpochSeconds = hour + 300, durationSeconds = 600)
|
||||
val run2 = workout("run2", exercise = ExerciseType.RUNNING, startTimeEpochSeconds = hour + 1200, durationSeconds = hour)
|
||||
|
||||
val result = WorkoutMerger.mergeCloseWorkouts(listOf(run1, walk, run2))
|
||||
|
||||
assertEquals(2, result.size)
|
||||
val run = result.first { it.exercise == ExerciseType.RUNNING }
|
||||
assertEquals("run1+run2", run.id)
|
||||
assertEquals(2, run.sessionCount)
|
||||
assertEquals(2 * hour, run.durationSeconds)
|
||||
assertEquals(1, result.first { it.exercise == ExerciseType.WALKING }.sessionCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heartRateIsDurationWeighted() {
|
||||
// 1h at 120 bpm + 0.5h at 150 bpm -> (120*3600 + 150*1800) / 5400 = 130.
|
||||
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour, avgHeartRate = 120, maxHeartRate = 140)
|
||||
val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800, avgHeartRate = 150, maxHeartRate = 175)
|
||||
|
||||
val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single()
|
||||
|
||||
assertEquals(130, merged.avgHeartRate)
|
||||
assertEquals(175, merged.maxHeartRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullMetricsAreSummedOnlyWhenPresent() {
|
||||
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour, distanceMeters = 10_000.0, calories = null)
|
||||
val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800, distanceMeters = null, calories = 200)
|
||||
|
||||
val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single()
|
||||
|
||||
// Distance present only on one -> that value survives; calories only on the other.
|
||||
assertEquals(10_000.0, merged.distanceMeters!!, 0.0001)
|
||||
assertEquals(200, merged.calories)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allNullMetricStaysNull() {
|
||||
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour)
|
||||
val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800)
|
||||
|
||||
val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single()
|
||||
|
||||
assertNull(merged.distanceMeters)
|
||||
assertNull(merged.calories)
|
||||
assertNull(merged.avgHeartRate)
|
||||
assertNull(merged.maxHeartRate)
|
||||
assertNull(merged.steps)
|
||||
assertNull(merged.elevationGainMeters)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun titleTakesFirstNonBlank() {
|
||||
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour, title = " ")
|
||||
val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800, title = "Morning long run")
|
||||
|
||||
val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single()
|
||||
|
||||
assertEquals("Morning long run", merged.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsortedInputProducesResultsOrderedByStart() {
|
||||
// Three same-type runs, all within an hour of each other, given out of order.
|
||||
val a = workout("a", startTimeEpochSeconds = 0, durationSeconds = 600)
|
||||
val b = workout("b", startTimeEpochSeconds = 1200, durationSeconds = 600)
|
||||
val c = workout("c", startTimeEpochSeconds = 2400, durationSeconds = 600)
|
||||
|
||||
val merged = WorkoutMerger.mergeCloseWorkouts(listOf(c, a, b)).single()
|
||||
|
||||
assertEquals("a+b+c", merged.id)
|
||||
assertEquals(3, merged.sessionCount)
|
||||
assertEquals(0, merged.startTimeEpochSeconds)
|
||||
assertEquals(1800, merged.durationSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chainMergesEvenWhenAdjacentGapsAreShortButEndsAreFarApart() {
|
||||
// 45-min sessions each starting 50 min apart: consecutive gaps are 5 min,
|
||||
// so the whole chain merges though first and last are hours apart.
|
||||
val sessions =
|
||||
(0 until 5).map {
|
||||
workout("s$it", startTimeEpochSeconds = it * 3000L, durationSeconds = 2700)
|
||||
}
|
||||
|
||||
val merged = WorkoutMerger.mergeCloseWorkouts(sessions)
|
||||
|
||||
assertEquals(1, merged.size)
|
||||
assertEquals(5, merged.single().sessionCount)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user