From a7b87524193cef0a9cc06b300c009fa9c64ead01 Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Sun, 2 Aug 2026 20:42:04 +0100 Subject: [PATCH 1/2] Converters for new data types: floors, sedentary period and activity levels --- .../ActivityLevelGoogleHealthAvroConverter.kt | 52 +++++++++++++++++++ .../FloorsGoogleHealthAvroConverter.kt | 45 ++++++++++++++++ ...edentaryPeriodGoogleHealthAvroConverter.kt | 43 +++++++++++++++ .../googlehealth/util/AvroBuilders.kt | 12 +++++ 4 files changed, 152 insertions(+) create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ActivityLevelGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/FloorsGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SedentaryPeriodGoogleHealthAvroConverter.kt diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ActivityLevelGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ActivityLevelGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..5d60dbea --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ActivityLevelGoogleHealthAvroConverter.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.googleHealthActivityLevel +import org.radarcns.push.googlehealth.GoogleHealthActivityLevelType + +/** + * Converts `activity-level` data points: the activity level the user sustained over an interval. + */ +class ActivityLevelGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["activityLevel"] ?: return emptyList() + val (start, end) = parseInterval(data) ?: return emptyList() + val record = googleHealthActivityLevel { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeInterval = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + level = mapLevel(data["activityLevelType"]?.asText()) + } + return listOf(user.observationKey to record) + } + + /** Google's `ACTIVITY_LEVEL_TYPE_UNSPECIFIED` and any future symbol map to `UNKNOWN`. */ + private fun mapLevel(text: String?): GoogleHealthActivityLevelType = when (text) { + "SEDENTARY" -> GoogleHealthActivityLevelType.SEDENTARY + "LIGHTLY_ACTIVE" -> GoogleHealthActivityLevelType.LIGHTLY_ACTIVE + "MODERATELY_ACTIVE" -> GoogleHealthActivityLevelType.MODERATELY_ACTIVE + "VERY_ACTIVE" -> GoogleHealthActivityLevelType.VERY_ACTIVE + else -> GoogleHealthActivityLevelType.UNKNOWN + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/FloorsGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/FloorsGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..a11734fb --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/FloorsGoogleHealthAvroConverter.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.googleHealthFloors + +/** + * Converts `floors` data points: elevation gained over an interval. + * Google documents `count` as an int64, serialised as a JSON string, `asInt` parses either form. + * The type supports true zeros, so a `count` of 0 is a real observation and is kept. + */ +class FloorsGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["floors"] ?: return emptyList() + val (start, end) = parseInterval(data) ?: return emptyList() + val count = data["count"]?.asInt() ?: return emptyList() + val record = googleHealthFloors { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeInterval = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + floors = count + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SedentaryPeriodGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SedentaryPeriodGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..d666998d --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SedentaryPeriodGoogleHealthAvroConverter.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.googleHealthSedentaryPeriod + +/** + * Converts `sedentary-period` data points: stretches during which the user was not moving while + * wearing the device. Google documents `interval` as this type's only field, so the record carries + * the interval alone. + */ +class SedentaryPeriodGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["sedentaryPeriod"] ?: return emptyList() + val (start, end) = parseInterval(data) ?: return emptyList() + val record = googleHealthSedentaryPeriod { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeInterval = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt index 966da91b..7467907e 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt @@ -9,6 +9,9 @@ import org.radarcns.push.googlehealth.GoogleHealthHeartRate import org.radarcns.push.googlehealth.GoogleHealthHeartRateVariability import org.radarcns.push.googlehealth.GoogleHealthOxygenSaturation import org.radarcns.push.googlehealth.GoogleHealthSteps +import org.radarcns.push.googlehealth.GoogleHealthFloors +import org.radarcns.push.googlehealth.GoogleHealthSedentaryPeriod +import org.radarcns.push.googlehealth.GoogleHealthActivityLevel import org.radarcns.push.googlehealth.GoogleHealthDailyRestingHeartRate import org.radarcns.push.googlehealth.GoogleHealthDailySleepTemperatureDerivations import org.radarcns.push.googlehealth.GoogleHealthSleepClassic @@ -28,6 +31,15 @@ inline fun googleHealthIrregularRhythmNotification(block: GoogleHealthIrregularR inline fun googleHealthSteps(block: GoogleHealthSteps.Builder.() -> Unit): GoogleHealthSteps = GoogleHealthSteps.newBuilder().apply(block).build() +inline fun googleHealthFloors(block: GoogleHealthFloors.Builder.() -> Unit): GoogleHealthFloors = + GoogleHealthFloors.newBuilder().apply(block).build() + +inline fun googleHealthSedentaryPeriod(block: GoogleHealthSedentaryPeriod.Builder.() -> Unit): GoogleHealthSedentaryPeriod = + GoogleHealthSedentaryPeriod.newBuilder().apply(block).build() + +inline fun googleHealthActivityLevel(block: GoogleHealthActivityLevel.Builder.() -> Unit): GoogleHealthActivityLevel = + GoogleHealthActivityLevel.newBuilder().apply(block).build() + inline fun googleHealthHeartRate(block: GoogleHealthHeartRate.Builder.() -> Unit): GoogleHealthHeartRate = GoogleHealthHeartRate.newBuilder().apply(block).build() From 63def30e03c2bdfe8b5d97a0c0770798f76bb267 Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Tue, 11 Aug 2026 15:19:52 +0100 Subject: [PATCH 2/2] Misc changes --- .../ActivityLevelGoogleHealthAvroConverter.kt | 12 ++++++++++-- .../converter/FloorsGoogleHealthAvroConverter.kt | 2 +- .../SedentaryPeriodGoogleHealthAvroConverter.kt | 9 ++++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ActivityLevelGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ActivityLevelGoogleHealthAvroConverter.kt index 5d60dbea..68a4a410 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ActivityLevelGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ActivityLevelGoogleHealthAvroConverter.kt @@ -23,7 +23,9 @@ import org.radarbase.googlehealth.util.googleHealthActivityLevel import org.radarcns.push.googlehealth.GoogleHealthActivityLevelType /** - * Converts `activity-level` data points: the activity level the user sustained over an interval. + * Converts `activity-level` data points: the activity level the user sustained over an interval, + * one minute long in practice, reported for every interval of the day. The sedentary intervals are + * also reported grouped into longer periods, see [SedentaryPeriodGoogleHealthAvroConverter]. */ class ActivityLevelGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( @@ -41,12 +43,18 @@ class ActivityLevelGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroCo return listOf(user.observationKey to record) } - /** Google's `ACTIVITY_LEVEL_TYPE_UNSPECIFIED` and any future symbol map to `UNKNOWN`. */ + /** + * Google's `ACTIVITY_LEVEL_TYPE_UNSPECIFIED` is kept as its own symbol, it means Google itself + * did not classify the interval. Any other symbol, including ones Google adds later, is + * `UNKNOWN`. + */ private fun mapLevel(text: String?): GoogleHealthActivityLevelType = when (text) { "SEDENTARY" -> GoogleHealthActivityLevelType.SEDENTARY "LIGHTLY_ACTIVE" -> GoogleHealthActivityLevelType.LIGHTLY_ACTIVE "MODERATELY_ACTIVE" -> GoogleHealthActivityLevelType.MODERATELY_ACTIVE "VERY_ACTIVE" -> GoogleHealthActivityLevelType.VERY_ACTIVE + "ACTIVITY_LEVEL_TYPE_UNSPECIFIED" -> + GoogleHealthActivityLevelType.ACTIVITY_LEVEL_TYPE_UNSPECIFIED else -> GoogleHealthActivityLevelType.UNKNOWN } } diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/FloorsGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/FloorsGoogleHealthAvroConverter.kt index a11734fb..99d90458 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/FloorsGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/FloorsGoogleHealthAvroConverter.kt @@ -36,8 +36,8 @@ class FloorsGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter val count = data["count"]?.asInt() ?: return emptyList() val record = googleHealthFloors { time = epochSeconds(start) + endTime = epochSeconds(end) timeReceived = nowEpochSeconds() - timeInterval = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) floors = count } return listOf(user.observationKey to record) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SedentaryPeriodGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SedentaryPeriodGoogleHealthAvroConverter.kt index d666998d..0ecb4ec5 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SedentaryPeriodGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SedentaryPeriodGoogleHealthAvroConverter.kt @@ -23,8 +23,11 @@ import org.radarbase.googlehealth.util.googleHealthSedentaryPeriod /** * Converts `sedentary-period` data points: stretches during which the user was not moving while - * wearing the device. Google documents `interval` as this type's only field, so the record carries - * the interval alone. + * wearing the device. Google groups each unbroken run of sedentary time into one point, so the + * points vary in length from minutes to hours and only cover the sedentary parts of the day. + * `activity-level` carries the same signal per minute, see + * [ActivityLevelGoogleHealthAvroConverter]. Google documents `interval` as this type's only field, + * so the record carries the start and end of that interval alone. */ class SedentaryPeriodGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( @@ -35,8 +38,8 @@ class SedentaryPeriodGoogleHealthAvroConverter(topic: String) : GoogleHealthAvro val (start, end) = parseInterval(data) ?: return emptyList() val record = googleHealthSedentaryPeriod { time = epochSeconds(start) + endTime = epochSeconds(end) timeReceived = nowEpochSeconds() - timeInterval = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) } return listOf(user.observationKey to record) }