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()