Skip to content

Commit 34376a9

Browse files
committed
HealthStatsSync: implement per-weekday typical-step binning
Implements buildWeekdayTypicalsFromData: single-pass binning of HealthDataEntity rows into 7×96 (weekday × 15-min slot) aggregates, with per-slot day-count tracking so a slot that was uncovered on some matching days isn't underweighted. Weekdays below the 2-day minimum-history threshold are omitted; per-slot gaps emit the 0xFFFF sentinel the watch firmware sum-skips. Tests cover empty input, below-threshold skip, sentinel emission, and the per-slot-vs-per-day averaging distinction.
1 parent fa8a26e commit 34376a9

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,41 @@ internal fun buildWeekdayTypicalsFromData(
194194
timeZone: TimeZone,
195195
): Map<DayOfWeek, ByteArray> {
196196
if (allData.isEmpty()) return emptyMap()
197-
return emptyMap()
197+
198+
val slotSteps = Array(7) { LongArray(TYPICAL_STEP_BINS) }
199+
val slotDays = Array(7) { Array(TYPICAL_STEP_BINS) { mutableSetOf<Long>() } }
200+
val matchingDays = Array(7) { mutableSetOf<Long>() }
201+
202+
for (entry in allData) {
203+
val entryDate = kotlinx.datetime.Instant.fromEpochSeconds(entry.timestamp)
204+
.toLocalDateTime(timeZone).date
205+
val wd = entryDate.dayOfWeek.ordinal
206+
val dayStart = entryDate.atStartOfDayIn(timeZone).epochSeconds
207+
val slot = ((entry.timestamp - dayStart) / TYPICAL_STEP_BIN_SECONDS)
208+
.toInt()
209+
.coerceIn(0, TYPICAL_STEP_BINS - 1)
210+
slotSteps[wd][slot] += entry.steps.toLong()
211+
slotDays[wd][slot].add(dayStart)
212+
matchingDays[wd].add(dayStart)
213+
}
214+
215+
val result = mutableMapOf<DayOfWeek, ByteArray>()
216+
for (wd in 0..6) {
217+
if (matchingDays[wd].size < MIN_DAYS_FOR_TYPICAL) continue
218+
val buffer = DataBuffer(TYPICAL_STEP_BINS * UShort.SIZE_BYTES)
219+
.apply { setEndian(Endian.Little) }
220+
for (slot in 0 until TYPICAL_STEP_BINS) {
221+
val count = slotDays[wd][slot].size
222+
val value: UShort = if (count == 0) {
223+
UNKNOWN_TYPICAL_STEPS
224+
} else {
225+
(slotSteps[wd][slot] / count).coerceIn(0L, 0xFFFEL).toInt().toUShort()
226+
}
227+
buffer.putUShort(value)
228+
}
229+
result[DayOfWeek.entries[wd]] = buffer.array().toByteArray()
230+
}
231+
return result
198232
}
199233

200234
// Extension functions

libpebble3/src/commonTest/kotlin/io/rebble/libpebblecommon/services/HealthStatsSyncTest.kt

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
package io.rebble.libpebblecommon.services
22

33
import io.rebble.libpebblecommon.database.entity.HealthDataEntity
4+
import kotlinx.datetime.DatePeriod
5+
import kotlinx.datetime.DayOfWeek
6+
import kotlinx.datetime.LocalDate
47
import kotlinx.datetime.TimeZone
8+
import kotlinx.datetime.atStartOfDayIn
9+
import kotlinx.datetime.plus
510
import kotlin.test.Test
11+
import kotlin.test.assertEquals
12+
import kotlin.test.assertFalse
13+
import kotlin.test.assertNotNull
614
import kotlin.test.assertTrue
715

816
class HealthStatsSyncTest {
@@ -11,6 +19,95 @@ class HealthStatsSyncTest {
1119
val result = buildWeekdayTypicalsFromData(emptyList(), TimeZone.UTC)
1220
assertTrue(result.isEmpty(), "empty input should produce empty map, got keys=${result.keys}")
1321
}
22+
23+
@Test
24+
fun buildWeekdayTypicalsFromData_singleMatchingDay_skipsWeekday() {
25+
// 2026-01-05 is a Monday. Provide rows for only that one Monday;
26+
// MIN_DAYS_FOR_TYPICAL = 2, so Monday must be absent from the result.
27+
val mondayStart = LocalDate(2026, 1, 5).atStartOfDayIn(TimeZone.UTC).epochSeconds
28+
val rows = (0..23).map { hour ->
29+
row(timestamp = mondayStart + hour * 3600L, steps = 100)
30+
}
31+
32+
val result = buildWeekdayTypicalsFromData(rows, TimeZone.UTC)
33+
34+
assertFalse(
35+
result.containsKey(DayOfWeek.MONDAY),
36+
"Monday should be absent with only 1 matching day, got keys=${result.keys}",
37+
)
38+
}
39+
40+
@Test
41+
fun buildWeekdayTypicalsFromData_twoMondays_producesPayload() {
42+
// Two Mondays meet MIN_DAYS_FOR_TYPICAL=2. Each Monday has one row in slot 32
43+
// (08:00-08:15) with 200 steps. Expected: result has MONDAY, slot 32 = 200.
44+
val mon1 = LocalDate(2026, 1, 5).atStartOfDayIn(TimeZone.UTC).epochSeconds
45+
val mon2 = LocalDate(2026, 1, 12).atStartOfDayIn(TimeZone.UTC).epochSeconds
46+
val slot32Offset = 32 * 900L
47+
48+
val rows = listOf(
49+
row(timestamp = mon1 + slot32Offset, steps = 200),
50+
row(timestamp = mon2 + slot32Offset, steps = 200),
51+
)
52+
53+
val result = buildWeekdayTypicalsFromData(rows, TimeZone.UTC)
54+
55+
val payload = result[DayOfWeek.MONDAY]
56+
assertNotNull(payload, "Monday payload missing; got keys=${result.keys}")
57+
assertEquals(192, payload.size, "payload should be 96 * 2 = 192 bytes")
58+
assertEquals(200, readUShortLE(payload, 32), "slot 32 should be 200")
59+
}
60+
61+
@Test
62+
fun buildWeekdayTypicalsFromData_slotWithNoData_writesSentinel() {
63+
// Two Mondays each with a row in slot 32. Slot 0 has no data on either Monday.
64+
// Expected: slot 0 = 0xFFFF (sentinel).
65+
val mon1 = LocalDate(2026, 1, 5).atStartOfDayIn(TimeZone.UTC).epochSeconds
66+
val mon2 = LocalDate(2026, 1, 12).atStartOfDayIn(TimeZone.UTC).epochSeconds
67+
val slot32Offset = 32 * 900L
68+
69+
val rows = listOf(
70+
row(timestamp = mon1 + slot32Offset, steps = 200),
71+
row(timestamp = mon2 + slot32Offset, steps = 200),
72+
)
73+
74+
val payload = buildWeekdayTypicalsFromData(rows, TimeZone.UTC)[DayOfWeek.MONDAY]
75+
assertNotNull(payload)
76+
assertEquals(0xFFFF, readUShortLE(payload, 0), "slot 0 must be UNKNOWN sentinel")
77+
assertEquals(0xFFFF, readUShortLE(payload, 95), "slot 95 must be UNKNOWN sentinel")
78+
}
79+
80+
@Test
81+
fun buildWeekdayTypicalsFromData_partialSlotCoverage_avgIsPerSlotNotPerDay() {
82+
// Five Mondays: each contributes 100 steps in slot 60. Two more Mondays exist
83+
// (with rows in OTHER slots) so they count toward matchingDays but NOT toward
84+
// slot 60's per-slot day count. Expected slot 60 = 100 (sum=500, slot-day-count=5),
85+
// NOT 71 (sum=500, total-matching-days=7).
86+
val mondays = (0..6).map { week ->
87+
LocalDate(2026, 1, 5).plus(DatePeriod(days = 7 * week)).atStartOfDayIn(TimeZone.UTC).epochSeconds
88+
}
89+
val slot60Offset = 60 * 900L
90+
val slot10Offset = 10 * 900L
91+
92+
val rows = buildList {
93+
// First 5 Mondays: rows in slot 60 with 100 steps
94+
for (i in 0..4) add(row(timestamp = mondays[i] + slot60Offset, steps = 100))
95+
// Last 2 Mondays: rows in slot 10 only (count toward matchingDays, not slot-60-day-count)
96+
for (i in 5..6) add(row(timestamp = mondays[i] + slot10Offset, steps = 50))
97+
}
98+
99+
val payload = buildWeekdayTypicalsFromData(rows, TimeZone.UTC)[DayOfWeek.MONDAY]
100+
assertNotNull(payload)
101+
assertEquals(100, readUShortLE(payload, 60), "slot 60 must average over per-slot days (5), not total matching days (7)")
102+
}
103+
104+
/** Reads a single little-endian UShort at byte offset slotIndex * 2 from payload. Returns Int 0..65535. */
105+
private fun readUShortLE(payload: ByteArray, slotIndex: Int): Int {
106+
val byteOffset = slotIndex * 2
107+
val lo = payload[byteOffset].toInt() and 0xFF
108+
val hi = payload[byteOffset + 1].toInt() and 0xFF
109+
return (hi shl 8) or lo
110+
}
14111
}
15112

16113
private fun row(timestamp: Long, steps: Int) = HealthDataEntity(

0 commit comments

Comments
 (0)