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
42 changes: 42 additions & 0 deletions google-health-library/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@

group = 'org.radarbase'
version = '0.0.1'

apply plugin: 'maven-publish'

repositories {
mavenCentral()
}

dependencies {
implementation libs.kotlin.stdlib

implementation libs.okhttp

implementation libs.radar.schemas.commons

implementation libs.jackson.annotations

implementation libs.jackson.databind

implementation libs.avro

implementation libs.jackson.datatype.jsr310

testImplementation libs.kotlin.test

testImplementation libs.kotlin.test.junit
}

project.afterEvaluate {
publishing {
publications {
library(MavenPublication) {
setGroupId "$group"
setArtifactId "google-health-library"
version "$version"
from components.java
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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 java.io.IOException

interface AvroConverter {

val topic: String

@Throws(IOException::class)
fun convert(
tree: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>>
}
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.restingHeartRate

class DailyRestingHeartRateGoogleHealthAvroConverter(topic: String) :
GoogleHealthAvroConverter(topic) {
override fun convertDataPoint(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>> {
val data = point["dailyRestingHeartRate"] ?: return emptyList()
val dateNode = data["date"] ?: return emptyList()
val bpm = data["beatsPerMinute"]?.asInt() ?: return emptyList()
val isoDate = String.format(
"%04d-%02d-%02d",
dateNode["year"].asInt(),
dateNode["month"].asInt(),
dateNode["day"].asInt(),
)
val record = restingHeartRate {
date = isoDate
timeReceived = nowEpochSeconds()
restingHeartRate = bpm
}
return listOf(user.observationKey to record)
}
}
Original file line number Diff line number Diff line change
@@ -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.skinTemperature
import org.radarcns.connector.fitbit.FitbitSkinTemperatureLogType

class DailySleepTemperatureDerivationsGoogleHealthAvroConverter(topic: String) :
GoogleHealthAvroConverter(topic) {
override fun convertDataPoint(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>> {
val data = point["dailySleepTemperatureDerivations"] ?: return emptyList()
val nightly = data["nightlyTemperatureCelsius"]?.takeIf { it.isNumber }?.floatValue() ?: return emptyList()
val baseline = data["baselineTemperatureCelsius"]?.takeIf { it.isNumber }?.floatValue() ?: return emptyList()
val time = parseDate(data) ?: return emptyList()
val record = skinTemperature {
this.time = epochSeconds(time)
timeReceived = nowEpochSeconds()
relativeTemperature = nightly - baseline
logType = FitbitSkinTemperatureLogType.UNKNOWN
}
return listOf(user.observationKey to record)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* 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.activityHeartRate
import org.radarbase.googlehealth.util.activityLogRecord

class ExerciseGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) {
override fun convertDataPoint(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>> {
val data = point["exercise"] ?: return emptyList()
val (start, end) = parseInterval(data) ?: return emptyList()
val offsetSeconds = parseUtcOffsetSeconds(
data["interval"]?.get("startUtcOffset")?.asText(),
)
val durationSec = (end.epochSecond - start.epochSecond).toFloat().coerceAtLeast(0.0f)
val metrics = data["metricsSummary"]
val distanceKm = metrics?.get("distanceMillimeters")?.takeIf { !it.isNull }
?.asDouble()?.let { it.toFloat() / 1_000_000f }
val caloriesKcal = metrics?.get("caloriesKcal")?.takeIf { !it.isNull }?.asDouble()
val energyKj = caloriesKcal?.let { (it * KCAL_TO_KJ).toFloat() }
val stepCount = metrics?.get("steps")?.takeIf { !it.isNull }?.asInt()
val avgHr = metrics?.get("averageHeartRateBeatsPerMinute")?.takeIf { !it.isNull }?.asInt()
val avgHeartRate = avgHr?.let { activityHeartRate { mean = it } }
val exerciseType = data["exerciseType"]?.asText()
val activityId = (point["name"]?.asText() ?: exerciseType ?: "")
.hashCode().toLong()
val record = activityLogRecord {
time = epochSeconds(start)
timeReceived = nowEpochSeconds()
timeZoneOffset = offsetSeconds
timeLastModified = epochSeconds(end)
duration = durationSec
durationActive = durationSec
id = activityId
name = data["displayName"]?.asText() ?: exerciseType
logType = point["dataSource"]?.get("recordingMethod")?.asText()
type = null
source = null
manualDataEntry = null
energy = energyKj
levels = null
heartRate = avgHeartRate
steps = stepCount
distance = distanceKm
speed = null
}
return listOf(user.observationKey to record)
}

companion object {
private const val KCAL_TO_KJ = 4.1868
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* 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 java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneOffset

abstract class GoogleHealthAvroConverter(override val topic: String) : AvroConverter {

abstract fun convertDataPoint(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>>

override fun convert(tree: JsonNode, user: User): List<Pair<SpecificRecord, SpecificRecord>> {
val points = tree["dataPoints"] ?: tree["rollupDataPoints"] ?: return emptyList()
if (!points.isArray) return emptyList()
return points.flatMap { convertDataPoint(it, user) }
}

companion object {
fun parseInterval(node: JsonNode): Pair<Instant, Instant>? {
val interval = node["interval"] ?: return null
val start = runCatching {
Instant.parse(interval["startTime"]?.asText() ?: return null)
}.getOrNull() ?: return null
val end = runCatching {
Instant.parse(interval["endTime"]?.asText() ?: return null)
}.getOrNull() ?: return null
return start to end
}

fun parseSampleTime(node: JsonNode): Instant? {
val sample = node["sampleTime"] ?: return null
val text = sample["physicalTime"]?.asText()?.takeIf { it.isNotEmpty() } ?: return null
return runCatching { Instant.parse(text) }.getOrNull()
}

fun parseDate(node: JsonNode, utcOffsetSeconds: Int = 0): Instant? {
val dateNode = node["date"] ?: return null
val year = dateNode["year"]?.asInt() ?: return null
val month = dateNode["month"]?.asInt() ?: return null
val day = dateNode["day"]?.asInt() ?: return null
val local = runCatching { LocalDate.of(year, month, day) }.getOrNull() ?: return null
val timeNode = node["time"]
val dateTime = if (timeNode != null && !timeNode.isNull) {
LocalDateTime.of(
local,
LocalTime.of(
timeNode["hours"]?.asInt() ?: 0,
timeNode["minutes"]?.asInt() ?: 0,
timeNode["seconds"]?.asInt() ?: 0,
timeNode["nanos"]?.asInt() ?: 0,
),
)
} else {
local.atStartOfDay()
}
return dateTime.toInstant(ZoneOffset.ofTotalSeconds(utcOffsetSeconds))
}

fun parseUtcOffsetSeconds(durationText: String?): Int {
if (durationText.isNullOrEmpty()) return 0
val trimmed = durationText.trim().removeSuffix("s")
val seconds = trimmed.toLongOrNull() ?: return 0
return seconds.toInt()
}

fun epochSeconds(instant: Instant): Double = instant.toEpochMilli() / 1000.0

fun nowEpochSeconds(): Double = Instant.now().toEpochMilli() / 1000.0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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.intradayHeartRate

class HeartRateGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) {
override fun convertDataPoint(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>> {
val data = point["heartRate"] ?: return emptyList()
val time = parseSampleTime(data) ?: return emptyList()
val bpm = data["beatsPerMinute"]?.asInt() ?: return emptyList()
val record = intradayHeartRate {
this.time = epochSeconds(time)
timeReceived = nowEpochSeconds()
timeInterval = 1
heartRate = bpm
}
return listOf(user.observationKey to record)
}
}
Loading
Loading