diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthActivityLevelAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthActivityLevelAvroConverter.kt new file mode 100644 index 00000000..7335adfe --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthActivityLevelAvroConverter.kt @@ -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 [GoogleHealthSedentaryPeriodAvroConverter]. + */ +class GoogleHealthActivityLevelAvroConverter(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` 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/GoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthAvroConverter.kt index 73022475..38c95b18 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthAvroConverter.kt @@ -21,6 +21,7 @@ import org.apache.avro.specific.SpecificRecord import org.radarbase.googlehealth.user.User import org.slf4j.Logger import org.slf4j.LoggerFactory +import java.io.IOException import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime @@ -31,6 +32,7 @@ abstract class GoogleHealthAvroConverter(override val topic: String) : AvroConve protected val logger: Logger = LoggerFactory.getLogger(javaClass) + @Throws(IOException::class) abstract fun convertDataPoint( point: JsonNode, user: User, diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailyRestingHeartRateGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthDailyRestingHeartRateAvroConverter.kt similarity index 96% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailyRestingHeartRateGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthDailyRestingHeartRateAvroConverter.kt index 99ab089b..f8ebc974 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailyRestingHeartRateGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthDailyRestingHeartRateAvroConverter.kt @@ -21,7 +21,7 @@ import org.apache.avro.specific.SpecificRecord import org.radarbase.googlehealth.user.User import org.radarbase.googlehealth.util.googleHealthDailyRestingHeartRate -class DailyRestingHeartRateGoogleHealthAvroConverter(topic: String) : +class GoogleHealthDailyRestingHeartRateAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailySleepTemperatureDerivationsGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthDailySleepTemperatureDerivationsAvroConverter.kt similarity index 90% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailySleepTemperatureDerivationsGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthDailySleepTemperatureDerivationsAvroConverter.kt index 71e45cc0..b2e559eb 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailySleepTemperatureDerivationsGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthDailySleepTemperatureDerivationsAvroConverter.kt @@ -21,15 +21,19 @@ import org.apache.avro.specific.SpecificRecord import org.radarbase.googlehealth.user.User import org.radarbase.googlehealth.util.googleHealthDailySleepTemperatureDerivations -class DailySleepTemperatureDerivationsGoogleHealthAvroConverter(topic: String) : +class GoogleHealthDailySleepTemperatureDerivationsAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, user: User, ): List> { 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 nightly = data["nightlyTemperatureCelsius"]?.takeIf { + it.isNumber + }?.floatValue() ?: return emptyList() + val baseline = data["baselineTemperatureCelsius"]?.takeIf { + it.isNumber + }?.floatValue() ?: return emptyList() // `date` is the civil date (in the user's timezone) the derivation is for — emit it // directly as a yyyy-MM-dd string, like DailyRestingHeartRate, rather than as a // UTC-midnight instant that could shift to the wrong local day downstream. diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ElectrocardiogramGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthElectrocardiogramAvroConverter.kt similarity index 74% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ElectrocardiogramGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthElectrocardiogramAvroConverter.kt index 15e1fc29..19835396 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ElectrocardiogramGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthElectrocardiogramAvroConverter.kt @@ -20,7 +20,10 @@ import com.fasterxml.jackson.databind.JsonNode import org.apache.avro.specific.SpecificRecord import org.radarbase.googlehealth.user.User import org.radarbase.googlehealth.util.googleHealthElectrocardiogram +import java.io.IOException import java.time.Instant +import java.util.stream.Collectors +import java.util.stream.IntStream /** * Emits one record per ECG waveform sample. Sample i is timed at the reading start plus @@ -29,7 +32,7 @@ import java.time.Instant * metadata (heart rate, sampling parameters, device info) is repeated on every sample's record, * linked by the shared reading id. */ -class ElectrocardiogramGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { +class GoogleHealthElectrocardiogramAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, user: User, @@ -38,16 +41,18 @@ class ElectrocardiogramGoogleHealthAvroConverter(topic: String) : GoogleHealthAv val start = data["interval"]?.get("startTime")?.asText() ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return emptyList() val id = (point["name"] ?: point["dataPointName"])?.asText()?.substringAfterLast('/') - ?: run { - logger.warn("Dropping electrocardiogram data point with no usable id for user={}", user.versionedId) - return emptyList() - } + ?: throw IOException( + "Electrocardiogram data point has no name or dataPointName to derive an id from " + + "for user=${user.versionedId}", + ) val samples = data["waveformSamples"]?.takeIf { it.isArray } ?: return emptyList() - val frequency = data["samplingFrequencyHertz"]?.takeIf { !it.isNull }?.asInt()?.takeIf { it > 0 } + val frequency = data["samplingFrequencyHertz"]?.takeIf { !it.isNull }?.asInt() + ?.takeIf { it > 0 } ?: return emptyList() val device = data["medicalDeviceInfo"] - val beatsPerMinuteAvg = data["beatsPerMinuteAvg"]?.takeIf { !it.isNull }?.asText()?.toIntOrNull() + val beatsPerMinuteAvg = data["beatsPerMinuteAvg"]?.takeIf { !it.isNull }?.asText() + ?.toIntOrNull() val scalingFactor = data["millivoltsScalingFactor"]?.takeIf { !it.isNull }?.asInt() val leadNumber = data["leadNumber"]?.takeIf { !it.isNull }?.asInt() val deviceModel = device?.get("deviceModel")?.asText() @@ -56,12 +61,19 @@ class ElectrocardiogramGoogleHealthAvroConverter(topic: String) : GoogleHealthAv val startSec = epochSeconds(start) val received = nowEpochSeconds() - return samples.mapIndexed { i, sampleNode -> + val sampleCount = samples.size() + if (sampleCount == 0) return emptyList() + + // A single reading carries thousands of samples (~7500 at 30 s / 250 Hz), each needing + // its own record. Every iteration only reads the parsed JSON tree and builds an + // independent Avro record, so the work spreads safely over the common pool; the stream + // stays ordered, keeping the samples in acquisition order. + return IntStream.range(0, sampleCount).parallel().mapToObj { i -> val record = googleHealthElectrocardiogram { time = startSec + i.toDouble() / frequency timeReceived = received this.id = id - sample = sampleNode.asInt() + sample = samples[i].asInt() this.beatsPerMinuteAvg = beatsPerMinuteAvg this.samplingFrequencyHertz = frequency this.millivoltsScalingFactor = scalingFactor @@ -71,6 +83,6 @@ class ElectrocardiogramGoogleHealthAvroConverter(topic: String) : GoogleHealthAv this.featureVersion = featureVersion } user.observationKey to record - } + }.collect(Collectors.toList()) } } diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ExerciseGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthExerciseAvroConverter.kt similarity index 82% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ExerciseGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthExerciseAvroConverter.kt index 5abd26e2..067471d0 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ExerciseGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthExerciseAvroConverter.kt @@ -19,12 +19,13 @@ 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.googleHealthExerciseHeartRate import org.radarbase.googlehealth.util.googleHealthExercise +import org.radarbase.googlehealth.util.googleHealthExerciseHeartRate import org.radarbase.googlehealth.util.googleHealthSource +import java.io.IOException import java.time.Instant -class ExerciseGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { +class GoogleHealthExerciseAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, user: User, @@ -44,21 +45,27 @@ class ExerciseGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConvert val distanceKm = metrics?.get("distanceMillimeters")?.takeIf { !it.isNull } ?.asText()?.toDoubleOrNull()?.let { it.toFloat() / 1_000_000f } - val caloriesKcal = metrics?.get("caloriesKcal")?.takeIf { !it.isNull }?.asText()?.toDoubleOrNull() + val caloriesKcal = metrics?.get( + "caloriesKcal", + )?.takeIf { !it.isNull }?.asText()?.toDoubleOrNull() val energyKj = caloriesKcal?.let { (it * KCAL_TO_KJ).toFloat() } val stepCount = metrics?.get("steps")?.takeIf { !it.isNull }?.asText()?.toIntOrNull() val speedKmh = metrics?.get("averageSpeedMillimetersPerSecond")?.takeIf { !it.isNull } ?.asText()?.toDoubleOrNull()?.let { it * MM_PER_S_TO_KM_PER_H } - val avgHr = metrics?.get("averageHeartRateBeatsPerMinute")?.takeIf { !it.isNull }?.asText()?.toIntOrNull() + val avgHr = metrics?.get("averageHeartRateBeatsPerMinute")?.takeIf { + !it.isNull + }?.asText()?.toIntOrNull() val zones = metrics?.get("heartRateZoneDurations")?.takeIf { !it.isNull } val avgHeartRate = if (avgHr != null || zones != null) { googleHealthExerciseHeartRate { mean = avgHr durationLight = zones?.get("lightTime")?.asText()?.let { parseDurationSeconds(it) } - durationModerate = zones?.get("moderateTime")?.asText()?.let { parseDurationSeconds(it) } - durationVigorous = zones?.get("vigorousTime")?.asText()?.let { parseDurationSeconds(it) } + durationModerate = zones?.get("moderateTime")?.asText() + ?.let { parseDurationSeconds(it) } + durationVigorous = zones?.get("vigorousTime")?.asText() + ?.let { parseDurationSeconds(it) } durationPeak = zones?.get("peakTime")?.asText()?.let { parseDurationSeconds(it) } } } else { @@ -76,12 +83,17 @@ class ExerciseGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConvert } } - val activityId = (point["name"] ?: point["dataPointName"])?.asText() - ?.substringAfterLast('/')?.toLongOrNull() - ?: run { - logger.warn("Dropping exercise data point with no usable log id for user={}", user.versionedId) - return emptyList() - } + val idSegment = (point["name"] ?: point["dataPointName"])?.asText() + ?.substringAfterLast('/') + ?: throw IOException( + "Exercise data point has no name or dataPointName to derive a log id from " + + "for user=${user.versionedId}", + ) + val activityId = idSegment.toLongOrNull() + ?: throw IOException( + "Exercise data point log id '$idSegment' is not numeric " + + "for user=${user.versionedId}", + ) val record = googleHealthExercise { time = epochSeconds(start) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthFloorsAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthFloorsAvroConverter.kt new file mode 100644 index 00000000..0e371c7f --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthFloorsAvroConverter.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 GoogleHealthFloorsAvroConverter(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) + endTime = epochSeconds(end) + timeReceived = nowEpochSeconds() + floors = count + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthHeartRateAvroConverter.kt similarity index 95% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthHeartRateAvroConverter.kt index e44491c1..9b562b6f 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthHeartRateAvroConverter.kt @@ -21,7 +21,7 @@ import org.apache.avro.specific.SpecificRecord import org.radarbase.googlehealth.user.User import org.radarbase.googlehealth.util.googleHealthHeartRate -class HeartRateGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { +class GoogleHealthHeartRateAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, user: User, diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateVariabilityGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthHeartRateVariabilityAvroConverter.kt similarity index 91% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateVariabilityGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthHeartRateVariabilityAvroConverter.kt index 322d0121..ebdd455b 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateVariabilityGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthHeartRateVariabilityAvroConverter.kt @@ -21,7 +21,7 @@ import org.apache.avro.specific.SpecificRecord import org.radarbase.googlehealth.user.User import org.radarbase.googlehealth.util.googleHealthHeartRateVariability -class HeartRateVariabilityGoogleHealthAvroConverter(topic: String) : +class GoogleHealthHeartRateVariabilityAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, @@ -29,7 +29,9 @@ class HeartRateVariabilityGoogleHealthAvroConverter(topic: String) : ): List> { val data = point["heartRateVariability"] ?: return emptyList() val time = parseSampleTime(data) ?: return emptyList() - val rmssd = data["rootMeanSquareOfSuccessiveDifferencesMilliseconds"]?.takeIf { it.isNumber }?.floatValue() + val rmssd = data["rootMeanSquareOfSuccessiveDifferencesMilliseconds"]?.takeIf { + it.isNumber + }?.floatValue() ?: return emptyList() val record = googleHealthHeartRateVariability { this.time = epochSeconds(time) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/IrregularRhythmNotificationGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthIrregularRhythmNotificationAvroConverter.kt similarity index 86% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/IrregularRhythmNotificationGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthIrregularRhythmNotificationAvroConverter.kt index 5753816a..26c9e895 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/IrregularRhythmNotificationGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthIrregularRhythmNotificationAvroConverter.kt @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode import org.apache.avro.specific.SpecificRecord import org.radarbase.googlehealth.user.User import org.radarbase.googlehealth.util.googleHealthIrregularRhythmNotification +import java.io.IOException import java.time.Instant /** @@ -28,17 +29,19 @@ import java.time.Instant * plus the context of its parent window (start/end, positive) and session (start), with the * device metadata repeated, linked by the shared notification id. */ -class IrregularRhythmNotificationGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { +class GoogleHealthIrregularRhythmNotificationAvroConverter( + topic: String, +) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, user: User, ): List> { val data = point["irregularRhythmNotification"] ?: return emptyList() val id = (point["name"] ?: point["dataPointName"])?.asText()?.substringAfterLast('/') - ?: run { - logger.warn("Dropping irregularRhythmNotification data point with no usable id for user={}", user.versionedId) - return emptyList() - } + ?: throw IOException( + "Irregular rhythm notification data point has no name or dataPointName to derive " + + "an id from for user=${user.versionedId}", + ) val windows = data["alertWindows"]?.takeIf { it.isArray } ?: return emptyList() val device = data["medicalDeviceInfo"] @@ -56,11 +59,13 @@ class IrregularRhythmNotificationGoogleHealthAvroConverter(topic: String) : Goog ?.let { runCatching { Instant.parse(it) }.getOrNull() } if (windowStart == null || windowEnd == null) return@flatMap emptyList() val positive = window["positive"]?.takeIf { !it.isNull }?.asBoolean() - val heartBeats = window["heartBeats"]?.takeIf { it.isArray } ?: return@flatMap emptyList() + val heartBeats = window["heartBeats"]?.takeIf { it.isArray } + ?: return@flatMap emptyList() heartBeats.mapNotNull { beat -> val beatTime = (beat["physicalTime"] ?: beat["time"])?.asText() - ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null + ?.let { runCatching { Instant.parse(it) }.getOrNull() } + ?: return@mapNotNull null val record = googleHealthIrregularRhythmNotification { time = epochSeconds(beatTime) timeReceived = received @@ -69,7 +74,8 @@ class IrregularRhythmNotificationGoogleHealthAvroConverter(topic: String) : Goog windowStartTime = epochSeconds(windowStart) windowEndTime = epochSeconds(windowEnd) this.positive = positive - beatsPerMinute = beat["beatsPerMinute"]?.takeIf { !it.isNull }?.asText()?.toIntOrNull() + beatsPerMinute = beat["beatsPerMinute"]?.takeIf { !it.isNull }?.asText() + ?.toIntOrNull() this.firmwareVersion = firmwareVersion this.featureVersion = featureVersion this.deviceModel = deviceModel diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/OxygenSaturationGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthOxygenSaturationAvroConverter.kt similarity index 95% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/OxygenSaturationGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthOxygenSaturationAvroConverter.kt index 21be1501..b653bf30 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/OxygenSaturationGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthOxygenSaturationAvroConverter.kt @@ -21,7 +21,7 @@ import org.apache.avro.specific.SpecificRecord import org.radarbase.googlehealth.user.User import org.radarbase.googlehealth.util.googleHealthOxygenSaturation -class OxygenSaturationGoogleHealthAvroConverter(topic: String) : +class GoogleHealthOxygenSaturationAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/RespiratoryRateSleepSummaryGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthRespiratoryRateSleepSummaryAvroConverter.kt similarity index 96% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/RespiratoryRateSleepSummaryGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthRespiratoryRateSleepSummaryAvroConverter.kt index 9e6c1d0e..2e3652f1 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/RespiratoryRateSleepSummaryGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthRespiratoryRateSleepSummaryAvroConverter.kt @@ -21,7 +21,7 @@ import org.apache.avro.specific.SpecificRecord import org.radarbase.googlehealth.user.User import org.radarbase.googlehealth.util.googleHealthRespiratoryRateSleepSummary -class RespiratoryRateSleepSummaryGoogleHealthAvroConverter(topic: String) : +class GoogleHealthRespiratoryRateSleepSummaryAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSedentaryPeriodAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSedentaryPeriodAvroConverter.kt new file mode 100644 index 00000000..d21b12a9 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSedentaryPeriodAvroConverter.kt @@ -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 + * [GoogleHealthActivityLevelAvroConverter]. Google documents `interval` as this type's only field, + * so the record carries the start and end of that interval alone. + */ +class GoogleHealthSedentaryPeriodAvroConverter(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) + endTime = epochSeconds(end) + timeReceived = nowEpochSeconds() + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepClassicGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepClassicAvroConverter.kt similarity index 93% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepClassicGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepClassicAvroConverter.kt index 288aad90..70133b76 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepClassicGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepClassicAvroConverter.kt @@ -26,7 +26,7 @@ import java.time.LocalDateTime import java.time.ZoneOffset import java.time.format.DateTimeFormatter -class SleepClassicGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { +class GoogleHealthSleepClassicAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, user: User, @@ -44,7 +44,9 @@ class SleepClassicGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroCon ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null // Render in the stage's own UTC offset so dateTime is the device's local wall clock // (like Fitbit), not UTC. Google derives its civil fields the same way (physical + offset). - val startZone = ZoneOffset.ofTotalSeconds(parseUtcOffsetSeconds(stage["startUtcOffset"]?.asText())) + val startZone = ZoneOffset.ofTotalSeconds( + parseUtcOffsetSeconds(stage["startUtcOffset"]?.asText()), + ) val record = googleHealthSleepClassic { dateTime = LOCAL_FMT.format(LocalDateTime.ofInstant(start, startZone)) this.timeReceived = timeReceived diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepStageGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepStageAvroConverter.kt similarity index 93% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepStageGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepStageAvroConverter.kt index 1161b77b..adbadb6d 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepStageGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepStageAvroConverter.kt @@ -26,7 +26,7 @@ import java.time.LocalDateTime import java.time.ZoneOffset import java.time.format.DateTimeFormatter -class SleepStageGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { +class GoogleHealthSleepStageAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, user: User, @@ -43,7 +43,9 @@ class SleepStageGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConve val end = stage["endTime"]?.asText() ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null - val startZone = ZoneOffset.ofTotalSeconds(parseUtcOffsetSeconds(stage["startUtcOffset"]?.asText())) + val startZone = ZoneOffset.ofTotalSeconds( + parseUtcOffsetSeconds(stage["startUtcOffset"]?.asText()), + ) val record = googleHealthSleepStage { dateTime = LOCAL_FMT.format(LocalDateTime.ofInstant(start, startZone)) this.timeReceived = timeReceived diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/StepsGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthStepsAvroConverter.kt similarity index 95% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/StepsGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthStepsAvroConverter.kt index 39f4c311..b4901737 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/StepsGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthStepsAvroConverter.kt @@ -21,7 +21,7 @@ import org.apache.avro.specific.SpecificRecord import org.radarbase.googlehealth.user.User import org.radarbase.googlehealth.util.googleHealthSteps -class StepsGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { +class GoogleHealthStepsAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, user: User, diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthTotalCaloriesAvroConverter.kt similarity index 93% rename from google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt rename to google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthTotalCaloriesAvroConverter.kt index 1d3eacc4..303fd3bc 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthTotalCaloriesAvroConverter.kt @@ -22,14 +22,15 @@ import org.radarbase.googlehealth.user.User import org.radarbase.googlehealth.util.googleHealthTotalCalories import java.time.Instant -class TotalCaloriesGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { +class GoogleHealthTotalCaloriesAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { override fun convertDataPoint( point: JsonNode, user: User, ): List> { val start = point["startTime"]?.asText()?.let(Instant::parse) ?: return emptyList() val end = point["endTime"]?.asText()?.let(Instant::parse) ?: return emptyList() - val kilocalories = point["totalCalories"]?.get("kcalSum")?.doubleValue() ?: return emptyList() + val kilocalories = point["totalCalories"]?.get("kcalSum")?.doubleValue() + ?: return emptyList() val record = googleHealthTotalCalories { time = epochSeconds(start) timeReceived = nowEpochSeconds() 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..5a30a46d 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 @@ -1,62 +1,97 @@ package org.radarbase.googlehealth.util -import org.radarcns.push.googlehealth.GoogleHealthExerciseHeartRate +import org.radarcns.push.googlehealth.GoogleHealthActivityLevel +import org.radarcns.push.googlehealth.GoogleHealthDailyRestingHeartRate +import org.radarcns.push.googlehealth.GoogleHealthDailySleepTemperatureDerivations +import org.radarcns.push.googlehealth.GoogleHealthElectrocardiogram import org.radarcns.push.googlehealth.GoogleHealthExercise -import org.radarcns.push.googlehealth.GoogleHealthSource -import org.radarcns.push.googlehealth.GoogleHealthRespiratoryRateSleepSummary -import org.radarcns.push.googlehealth.GoogleHealthTotalCalories +import org.radarcns.push.googlehealth.GoogleHealthExerciseHeartRate +import org.radarcns.push.googlehealth.GoogleHealthFloors import org.radarcns.push.googlehealth.GoogleHealthHeartRate import org.radarcns.push.googlehealth.GoogleHealthHeartRateVariability +import org.radarcns.push.googlehealth.GoogleHealthIrregularRhythmNotification import org.radarcns.push.googlehealth.GoogleHealthOxygenSaturation -import org.radarcns.push.googlehealth.GoogleHealthSteps -import org.radarcns.push.googlehealth.GoogleHealthDailyRestingHeartRate -import org.radarcns.push.googlehealth.GoogleHealthDailySleepTemperatureDerivations +import org.radarcns.push.googlehealth.GoogleHealthRespiratoryRateSleepSummary +import org.radarcns.push.googlehealth.GoogleHealthSedentaryPeriod import org.radarcns.push.googlehealth.GoogleHealthSleepClassic import org.radarcns.push.googlehealth.GoogleHealthSleepStage -import org.radarcns.push.googlehealth.GoogleHealthElectrocardiogram -import org.radarcns.push.googlehealth.GoogleHealthIrregularRhythmNotification +import org.radarcns.push.googlehealth.GoogleHealthSource +import org.radarcns.push.googlehealth.GoogleHealthSteps +import org.radarcns.push.googlehealth.GoogleHealthTotalCalories -inline fun googleHealthSource(block: GoogleHealthSource.Builder.() -> Unit): GoogleHealthSource = - GoogleHealthSource.newBuilder().apply(block).build() +inline fun googleHealthSource( + block: GoogleHealthSource.Builder.() -> Unit, +): GoogleHealthSource = GoogleHealthSource.newBuilder().apply(block).build() -inline fun googleHealthElectrocardiogram(block: GoogleHealthElectrocardiogram.Builder.() -> Unit): GoogleHealthElectrocardiogram = - GoogleHealthElectrocardiogram.newBuilder().apply(block).build() +inline fun googleHealthElectrocardiogram( + block: GoogleHealthElectrocardiogram.Builder.() -> Unit, +): GoogleHealthElectrocardiogram = GoogleHealthElectrocardiogram.newBuilder().apply(block).build() -inline fun googleHealthIrregularRhythmNotification(block: GoogleHealthIrregularRhythmNotification.Builder.() -> Unit): GoogleHealthIrregularRhythmNotification = +inline fun googleHealthIrregularRhythmNotification( + block: GoogleHealthIrregularRhythmNotification.Builder.() -> Unit, +): GoogleHealthIrregularRhythmNotification = GoogleHealthIrregularRhythmNotification.newBuilder().apply(block).build() -inline fun googleHealthSteps(block: GoogleHealthSteps.Builder.() -> Unit): GoogleHealthSteps = - GoogleHealthSteps.newBuilder().apply(block).build() +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() +inline fun googleHealthHeartRate( + block: GoogleHealthHeartRate.Builder.() -> Unit, +): GoogleHealthHeartRate = GoogleHealthHeartRate.newBuilder().apply(block).build() -inline fun googleHealthHeartRateVariability(block: GoogleHealthHeartRateVariability.Builder.() -> Unit): GoogleHealthHeartRateVariability = +inline fun googleHealthHeartRateVariability( + block: GoogleHealthHeartRateVariability.Builder.() -> Unit, +): GoogleHealthHeartRateVariability = GoogleHealthHeartRateVariability.newBuilder().apply(block).build() -inline fun googleHealthOxygenSaturation(block: GoogleHealthOxygenSaturation.Builder.() -> Unit): GoogleHealthOxygenSaturation = - GoogleHealthOxygenSaturation.newBuilder().apply(block).build() +inline fun googleHealthOxygenSaturation( + block: GoogleHealthOxygenSaturation.Builder.() -> Unit, +): GoogleHealthOxygenSaturation = GoogleHealthOxygenSaturation.newBuilder().apply(block).build() -inline fun googleHealthDailyRestingHeartRate(block: GoogleHealthDailyRestingHeartRate.Builder.() -> Unit): GoogleHealthDailyRestingHeartRate = +inline fun googleHealthDailyRestingHeartRate( + block: GoogleHealthDailyRestingHeartRate.Builder.() -> Unit, +): GoogleHealthDailyRestingHeartRate = GoogleHealthDailyRestingHeartRate.newBuilder().apply(block).build() -inline fun googleHealthRespiratoryRateSleepSummary(block: GoogleHealthRespiratoryRateSleepSummary.Builder.() -> Unit): GoogleHealthRespiratoryRateSleepSummary = +inline fun googleHealthRespiratoryRateSleepSummary( + block: GoogleHealthRespiratoryRateSleepSummary.Builder.() -> Unit, +): GoogleHealthRespiratoryRateSleepSummary = GoogleHealthRespiratoryRateSleepSummary.newBuilder().apply(block).build() -inline fun googleHealthDailySleepTemperatureDerivations(block: GoogleHealthDailySleepTemperatureDerivations.Builder.() -> Unit): GoogleHealthDailySleepTemperatureDerivations = +inline fun googleHealthDailySleepTemperatureDerivations( + block: GoogleHealthDailySleepTemperatureDerivations.Builder.() -> Unit, +): GoogleHealthDailySleepTemperatureDerivations = GoogleHealthDailySleepTemperatureDerivations.newBuilder().apply(block).build() -inline fun googleHealthSleepClassic(block: GoogleHealthSleepClassic.Builder.() -> Unit): GoogleHealthSleepClassic = - GoogleHealthSleepClassic.newBuilder().apply(block).build() +inline fun googleHealthSleepClassic( + block: GoogleHealthSleepClassic.Builder.() -> Unit, +): GoogleHealthSleepClassic = GoogleHealthSleepClassic.newBuilder().apply(block).build() -inline fun googleHealthSleepStage(block: GoogleHealthSleepStage.Builder.() -> Unit): GoogleHealthSleepStage = - GoogleHealthSleepStage.newBuilder().apply(block).build() +inline fun googleHealthSleepStage( + block: GoogleHealthSleepStage.Builder.() -> Unit, +): GoogleHealthSleepStage = GoogleHealthSleepStage.newBuilder().apply(block).build() -inline fun googleHealthExercise(block: GoogleHealthExercise.Builder.() -> Unit): GoogleHealthExercise = - GoogleHealthExercise.newBuilder().apply(block).build() +inline fun googleHealthExercise( + block: GoogleHealthExercise.Builder.() -> Unit, +): GoogleHealthExercise = GoogleHealthExercise.newBuilder().apply(block).build() -inline fun googleHealthExerciseHeartRate(block: GoogleHealthExerciseHeartRate.Builder.() -> Unit): GoogleHealthExerciseHeartRate = - GoogleHealthExerciseHeartRate.newBuilder().apply(block).build() +inline fun googleHealthExerciseHeartRate( + block: GoogleHealthExerciseHeartRate.Builder.() -> Unit, +): GoogleHealthExerciseHeartRate = GoogleHealthExerciseHeartRate.newBuilder().apply(block).build() -inline fun googleHealthTotalCalories(block: GoogleHealthTotalCalories.Builder.() -> Unit): GoogleHealthTotalCalories = - GoogleHealthTotalCalories.newBuilder().apply(block).build() +inline fun googleHealthTotalCalories( + block: GoogleHealthTotalCalories.Builder.() -> Unit, +): GoogleHealthTotalCalories = GoogleHealthTotalCalories.newBuilder().apply(block).build()