Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* 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,
* 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(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>> {
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` 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
}
}
Original file line number Diff line number Diff line change
@@ -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<Pair<SpecificRecord, SpecificRecord>> {
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)
endTime = epochSeconds(end)
timeReceived = nowEpochSeconds()
floors = count
}
return listOf(user.observationKey to record)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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 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(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>> {
val data = point["sedentaryPeriod"] ?: return emptyList()
val (start, end) = parseInterval(data) ?: return emptyList()
val record = googleHealthSedentaryPeriod {
time = epochSeconds(start)
endTime = epochSeconds(end)
timeReceived = nowEpochSeconds()
}
return listOf(user.observationKey to record)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand Down
Loading