diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c78356c5..20365a2d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,8 @@ firebaseStorageKtx = "21.0.0" foundation = "1.6.7" googleGmsGoogleServices = "4.4.2" googleid = "1.1.0" +hapiFhirVersion = "5.7.9" +healthConnectClient = "1.1.0-alpha07" hiltNavigation = "1.2.0" hiltVersion = "2.51" junit = "4.13.2" @@ -46,6 +48,7 @@ android-gradle = { group = "com.android.tools.build", name = "gradle", version.r android-tools-common = { group = "com.android.tools", name = "common", version.ref = "androidTools" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-health-connect-client = { module = "androidx.health.connect:connect-client", version.ref = "healthConnectClient" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "coreTestingVersion" } androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentialsPlayServicesAuth" } @@ -73,6 +76,7 @@ firebase-functions-ktx = { group = "com.google.firebase", name = "firebase-funct firebase-storage-ktx = { group = "com.google.firebase", name = "firebase-storage-ktx", version.ref = "firebaseStorageKtx" } google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" } +hapi-fhir-structures-r4 = { module = "ca.uhn.hapi.fhir:hapi-fhir-structures-r4", version.ref = "hapiFhirVersion"} hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hiltVersion" } hilt-core = { group = "com.google.dagger", name = "hilt-android", version.ref = "hiltVersion" } hilt-gradle = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hiltVersion" } diff --git a/modules/contact/src/androidTest/AndroidManifest.xml b/modules/contact/src/androidTest/AndroidManifest.xml deleted file mode 100644 index 69ba7552f..000000000 --- a/modules/contact/src/androidTest/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/modules/contact/src/main/AndroidManifest.xml b/modules/contact/src/main/AndroidManifest.xml index 44008a433..568741e54 100644 --- a/modules/contact/src/main/AndroidManifest.xml +++ b/modules/contact/src/main/AndroidManifest.xml @@ -1,4 +1,2 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/modules/healthconnectonfhir/.gitignore b/modules/healthconnectonfhir/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/modules/healthconnectonfhir/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/modules/healthconnectonfhir/README.md b/modules/healthconnectonfhir/README.md new file mode 100644 index 000000000..eb29720e4 --- /dev/null +++ b/modules/healthconnectonfhir/README.md @@ -0,0 +1,83 @@ +# Module healthconnectonfhir + +The HealthConnectOnFHIR library provides a mapper that converts supported [Android Health Connect](https://health.google/health-connect-android/) Records to corresponding [HL7® FHIR® R4 Observations](https://hl7.org/fhir/r4/observation.html) with standardized codes (e.g. [LOINC](https://loinc.org/)). + +For more information, please refer to the API documentation. + +## Mapping Table + +| Health Connect Record | FHIR Observation Category | LOINC Code | Unit | Display | +|------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|------------|------------|--------------------------------------------| +| [ActiveCaloriesBurnedRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/ActiveCaloriesBurnedRecord) | Activity | 41981-2 | kcal | Calories burned | +| [BloodGlucoseRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/BloodGlucoseRecord) | | 41653-7 | mg/dL | Glucose Glucometer (BldC) [Mass/Vol] | +| [BloodPressureRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/BloodPressureRecord) | Vital Signs | 85354-9 | mmHg | Blood pressure panel with all children optional | +| [BodyFatRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/BodyFatRecord) | | 41982-0 | % | Percentage of body fat Measured | +| [BodyTemperatureRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/BodyTemperatureRecord) | Vital Signs | 8310-5 | Cel | Body temperature | +| [HeartRateRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/HeartRateRecord) | Vital Signs | 8867-4 | /min | Heart rate | +| [HeightRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/HeightRecord) | Vital Signs | 8302-2 | m | Body height | +| [OxygenSaturationRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/OxygenSaturationRecord) | Vital Signs | 59408-5 | % | Oxygen saturation in Arterial blood by Pulse oximetry | +| [RespiratoryRateRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/RespiratoryRateRecord) | Vital Signs | 9279-1 | /min | Respiratory rate | +| [StepsRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/StepsRecord) | Activity | 55423-8 | steps | Number of steps | +| [WeightRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/WeightRecord) | Vital Signs | 29463-7 | kg | Body weight | + + +## Installation + +HealthConnectOnFHIR can be installed into your Android Studio project [via Jitpack](https://jitpack.io/#StanfordSpezi/SpeziKt/healthconnectonfhir-maven). + +## Usage + +```kotlin +// Initialize the mapper +val mapper = RecordToObservationMapperImpl() + +// Query a `Record` from Health Connect +val record = // .. + +// Map the record to an HL7 FHIR Observation +val observation = mapper.map(record) +``` + +## Example + +First, you will need to configure your application to use Android Health Connect. For more information, please see the [official documentation](https://developer.android.com/health-and-fitness/guides/health-connect). + +```kotlin +// Initialize a `HealthConnectClient` (see Health Connect docs for full details) +val healthConnectClient = HealthConnectClient.getOrCreate(context) + +// Define a time range for the query +val startTime = Instant.parse("2023-05-01T00:00:00Z") +val endTime = Instant.parse("2023-06-01T00:00:00Z") + +// Query a list of `WeightRecord`s from Health Connect +val result = healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = WeightRecord::class, + timeRangeFilter = TimeRangeFilter.between(startTime, endTime) + ) +).records + +// Initialize the mapper +val mapper = RecordToObservationMapperImpl() + +// Convert each weight record to a FHIR Observation +result.forEach { weightRecord -> + val observations = mapper.map(weightRecord) + observations.forEach { observation -> + // Do something with the observation + } +} +``` + +## License + +This project is licensed under the MIT license. + +## Contributors + +This project is developed as a part of the Stanford Biodesign for Digital Health projects at Stanford. See CONTRIBUTORS.md for a full list of all HealthConnectOnFHIR contributors. + +## Notices + +Health Connect is a registered trademark of Google. FHIR is a registered trademark of Health Level Seven International. \ No newline at end of file diff --git a/modules/healthconnectonfhir/build.gradle.kts b/modules/healthconnectonfhir/build.gradle.kts new file mode 100644 index 000000000..cd299f4d1 --- /dev/null +++ b/modules/healthconnectonfhir/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.spezi.library) + alias(libs.plugins.spezi.hilt) +} + +android { + namespace = "edu.stanford.spezi.modules.healthconnectonfhir" +} + +dependencies { + api(libs.androidx.health.connect.client) + api(libs.hapi.fhir.structures.r4) +} diff --git a/modules/healthconnectonfhir/src/main/AndroidManifest.xml b/modules/healthconnectonfhir/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a5918e68a --- /dev/null +++ b/modules/healthconnectonfhir/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/MappedUnit.kt b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/MappedUnit.kt new file mode 100644 index 000000000..5d0180015 --- /dev/null +++ b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/MappedUnit.kt @@ -0,0 +1,3 @@ +package edu.stanford.healthconnectonfhir + +data class MappedUnit(val unit: String, val system: String, val code: String) diff --git a/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapper.kt b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapper.kt new file mode 100644 index 000000000..3e382ad6a --- /dev/null +++ b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapper.kt @@ -0,0 +1,15 @@ +package edu.stanford.healthconnectonfhir + +import androidx.health.connect.client.records.Record +import org.hl7.fhir.r4.model.Observation + +interface RecordToObservationMapper { + /** + * Maps a given Health Connect record to a list of HL7 FHIR Observations + * + * @param T the type of the Health Connect record, extending from `Record` + * @param record the record to be mapped + * @return a list of `Observation` objects derived from the provided health record + */ + fun map(record: T): List +} diff --git a/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperImpl.kt b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperImpl.kt new file mode 100644 index 000000000..48de42529 --- /dev/null +++ b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperImpl.kt @@ -0,0 +1,450 @@ +package edu.stanford.healthconnectonfhir + +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BloodPressureRecord +import androidx.health.connect.client.records.BodyFatRecord +import androidx.health.connect.client.records.BodyTemperatureRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.HeightRecord +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.RespiratoryRateRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.WeightRecord +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Period +import org.hl7.fhir.r4.model.Quantity +import java.util.Date +import javax.inject.Inject + +class RecordToObservationMapperImpl @Inject constructor() : RecordToObservationMapper { + /** + * Maps a given Health Connect record to a list of HL7 FHIR Observations. + * + * @param T the type of the health record, extending from Record + * @param record the health record to be mapped + * @return a list of Observation objects derived from the provided health record + * @throws IllegalArgumentException if the record type is unsupported + */ + override fun map(record: T): List { + return when (record) { + is ActiveCaloriesBurnedRecord -> listOf(mapActiveCaloriesBurnedRecord(record)) + is BodyFatRecord -> listOf(mapBodyFatRecord(record)) + is BodyTemperatureRecord -> listOf(mapBodyTemperatureRecord(record)) + is BloodGlucoseRecord -> listOf(mapBloodGlucoseRecord(record)) + is BloodPressureRecord -> listOf(mapBloodPressureRecord(record)) + is HeartRateRecord -> mapHeartRateRecord(record) + is HeightRecord -> listOf(mapHeightRecord(record)) + is OxygenSaturationRecord -> listOf(mapOxygenSaturationRecord(record)) + is RespiratoryRateRecord -> listOf(mapRespiratoryRateRecord(record)) + is StepsRecord -> listOf(mapStepsRecord(record)) + is WeightRecord -> listOf(mapWeightRecord(record)) + else -> throw IllegalArgumentException("Unsupported record type ${record.javaClass.name}") + } + } + + /** + * Maps an ActiveCaloriesBurnedRecord to a FHIR Observation. + * + * @param record the ActiveCaloriesBurnedRecord to be mapped + * @return an Observation object derived from the provided ActiveCaloriesBurnedRecord + */ + private fun mapActiveCaloriesBurnedRecord(record: ActiveCaloriesBurnedRecord) = record.createObservation( + categories = listOf( + Coding() + .setSystem("http://terminology.hl7.org/CodeSystem/observation-category") + .setCode("activity") + .setDisplay("Activity") + ), + codings = listOf( + Coding() + .setSystem("http://loinc.org") + .setCode("41981-2") + .setDisplay("Calories burned") + ), + unit = MappedUnit( + code = "kcal", + unit = "kcal", + system = "http://unitsofmeasure.org" + ), + valueExtractor = { energy.inCalories }, + periodExtractor = { Date.from(startTime) to Date.from(endTime) } + ) + + /** + * Maps a BloodGlucoseRecord to a FHIR Observation. + * + * @param record the BloodGlucoseRecord to be mapped + * @return an Observation object derived from the provided BloodGlucoseRecord + */ + private fun mapBloodGlucoseRecord(record: BloodGlucoseRecord) = record.createObservation( + codings = listOf( + Coding() + .setSystem("http://loinc.org") + .setCode("41653-7") + .setDisplay("Glucose Glucometer (BldC) [Mass/Vol]") + ), + unit = MappedUnit( + code = "mg/dL", + unit = "mg/dL", + system = "http://unitsofmeasure.org" + ), + valueExtractor = { level.inMilligramsPerDeciliter }, + periodExtractor = { Date.from(time) to Date.from(time) } + ) + + /** + * Maps a BloodPressureRecord to a FHIR Observation. + * + * @param record the BloodPressureRecord to be mapped + * @return an Observation object derived from the provided BloodPressureRecord in which + * the systolic and diastolic blood pressure values are represented as separate components. + */ + private fun mapBloodPressureRecord(record: BloodPressureRecord): Observation { + val observation = Observation() + + observation.addCommonElements() + + observation.identifier = listOf( + Identifier().apply { + this.setId(record.metadata.id) + } + ) + + observation.category = listOf( + CodeableConcept().addCoding( + Coding() + .setSystem("http://terminology.hl7.org/CodeSystem/observation-category") + .setCode("vital-signs") + .setDisplay("Vital Signs") + ) + ) + + observation.code = CodeableConcept().addCoding( + Coding() + .setSystem("http://loinc.org") + .setCode("85354-9") + .setDisplay("Blood pressure panel with all children optional") + ) + + val dateTime = DateTimeType(Date.from(record.time)) + observation.effective = dateTime + + val systolicComponent = Observation.ObservationComponentComponent() + systolicComponent.code = CodeableConcept().addCoding( + Coding() + .setSystem("http://loinc.org") + .setCode("8480-6") + .setDisplay("Systolic blood pressure") + ) + systolicComponent.value = Quantity() + .setValue(record.systolic.inMillimetersOfMercury) + .setUnit("mmHg") + .setCode("mm[Hg]") + .setSystem("http://unitsofmeasure.org") + + val diastolicComponent = Observation.ObservationComponentComponent() + diastolicComponent.code = CodeableConcept().addCoding( + Coding() + .setSystem("http://loinc.org") + .setCode("8462-4") + .setDisplay("Diastolic blood pressure") + ) + diastolicComponent.value = Quantity() + .setValue(record.diastolic.inMillimetersOfMercury) + .setUnit("mmHg") + .setCode("mm[Hg]") + .setSystem("http://unitsofmeasure.org") + + observation.addComponent(systolicComponent) + observation.addComponent(diastolicComponent) + + return observation + } + + /** + * Maps a BodyFatRecord to a FHIR Observation. + * + * @param record the BodyFatRecord to be mapped + * @return an Observation object derived from the provided BodyFatRecord + */ + private fun mapBodyFatRecord(record: BodyFatRecord) = record.createObservation( + codings = listOf( + Coding() + .setSystem("http://loinc.org") + .setCode("41982-0") + .setDisplay("Percentage of body fat Measured") + ), + unit = MappedUnit( + code = "%", + system = "http://unitsofmeasure.org", + unit = "%" + ), + valueExtractor = { percentage.value }, + periodExtractor = { Date.from(time) to Date.from(time) } + ) + + /** + * Maps a BodyTemperatureRecord to a FHIR Observation. + * + * @param record the BodyTemperatureRecord to be mapped + * @return an Observation object derived from the provided BodyTemperatureRecord + */ + private fun mapBodyTemperatureRecord(record: BodyTemperatureRecord) = record.createObservation( + categories = listOf( + Coding() + .setSystem("http://terminology.hl7.org/CodeSystem/observation-category") + .setCode("vital-signs") + .setDisplay("Vital Signs") + ), + codings = listOf( + Coding() + .setSystem("http://loinc.org") + .setCode("8310-5") + .setDisplay("Body temperature") + ), + unit = MappedUnit( + code = "Cel", + system = "http://unitsofmeasure.org", + unit = "C" + ), + valueExtractor = { temperature.inCelsius }, + periodExtractor = { Date.from(time) to Date.from(time) } + ) + + /** + * Maps a HeartRateRecord to a list of FHIR Observations. + * + * @param record the HeartRateRecord to be mapped + * @return a list of Observation objects derived from the provided HeartRateRecord. + * Each object represents a single sample from the HeartRateRecord. + */ + private fun mapHeartRateRecord(record: HeartRateRecord): List { + return record.samples.map { sample -> + val observation = Observation() + + observation.addCommonElements() + + observation.category = listOf( + CodeableConcept().addCoding( + Coding() + .setSystem("http://terminology.hl7.org/CodeSystem/observation-category") + .setCode("vital-signs") + .setDisplay("Vital Signs") + ) + ) + + observation.code = CodeableConcept().addCoding( + Coding() + .setSystem("http://loinc.org") + .setCode("8867-4") + .setDisplay("Heart rate") + ) + + val dateTime = DateTimeType(Date.from(sample.time)) + observation.effective = dateTime + + observation.value = Quantity() + .setValue(sample.beatsPerMinute) + .setUnit("beats/minute") + .setCode("/min") + .setSystem("http://unitsofmeasure.org") + + observation + } + } + + /** + * Maps a HeightRecord to a FHIR Observation. + * + * @param record the HeightRecord to be mapped + * @return an Observation object derived from the provided HeightRecord + */ + private fun mapHeightRecord(record: HeightRecord) = record.createObservation( + categories = listOf( + Coding() + .setSystem("http://terminology.hl7.org/CodeSystem/observation-category") + .setCode("vital-signs") + .setDisplay("Vital Signs") + ), + codings = listOf( + Coding() + .setSystem("http://loinc.org") + .setCode("8302-2") + .setDisplay("Body height") + ), + unit = MappedUnit( + code = "m", + system = "http://unitsofmeasure.org", + unit = "m" + ), + valueExtractor = { height.inMeters }, + periodExtractor = { Date.from(time) to Date.from(time) } + ) + + /** + * Maps an OxygenSaturationRecord to a FHIR Observation. + * + * @param record the OxygenSaturationRecord to be mapped + * @return an Observation object derived from the provided OxygenSaturationRecord + */ + private fun mapOxygenSaturationRecord(record: OxygenSaturationRecord) = record.createObservation( + categories = listOf( + Coding() + .setSystem("http://terminology.hl7.org/CodeSystem/observation-category") + .setCode("vital-signs") + .setDisplay("Vital Signs") + ), + codings = listOf( + Coding() + .setSystem("http://loinc.org") + .setCode("59408-5") + .setDisplay("Oxygen saturation in Arterial blood by Pulse oximetry") + ), + unit = MappedUnit( + code = "%", + system = "http://unitsofmeasure.org", + unit = "%" + ), + valueExtractor = { percentage.value }, + periodExtractor = { Date.from(time) to Date.from(time) } + ) + + /** + * Maps a RespiratoryRateRecord to a FHIR Observation. + * + * @param record the RespiratoryRateRecord to be mapped + * @return an Observation object derived from the provided RespiratoryRateRecord + */ + private fun mapRespiratoryRateRecord(record: RespiratoryRateRecord) = record.createObservation( + categories = listOf( + Coding() + .setSystem("http://terminology.hl7.org/CodeSystem/observation-category") + .setCode("vital-signs") + .setDisplay("Vital Signs") + ), + codings = listOf( + Coding() + .setSystem("http://loinc.org") + .setCode("9279-1") + .setDisplay("Respiratory rate") + ), + unit = MappedUnit( + code = "/min", + system = "http://unitsofmeasure.org", + unit = "breaths/minute" + ), + valueExtractor = { rate }, + periodExtractor = { Date.from(time) to Date.from(time) } + ) + + /** + * Maps a StepsRecord to a FHIR Observation. + * + * @param record the StepsRecord to be mapped + * @return an Observation object derived from the provided StepsRecord + */ + private fun mapStepsRecord(record: StepsRecord) = record.createObservation( + categories = listOf( + Coding() + .setSystem("http://terminology.hl7.org/CodeSystem/observation-category") + .setCode("activity") + .setDisplay("Activity") + ), + codings = listOf( + Coding() + .setSystem("http://loinc.org") + .setCode("55423-8") + .setDisplay("Number of steps") + ), + unit = MappedUnit( + unit = "steps", + code = "", + system = "" + ), + valueExtractor = { count.toDouble() }, + periodExtractor = { Date.from(startTime) to Date.from(endTime) } + ) + + /** + * Maps a WeightRecord to a FHIR Observation. + * + * @param record the WeightRecord to be mapped + * @return an Observation object derived from the provided WeightRecord + */ + private fun mapWeightRecord(record: WeightRecord) = record.createObservation( + categories = listOf( + Coding() + .setSystem("http://terminology.hl7.org/CodeSystem/observation-category") + .setCode("vital-signs") + .setDisplay("Vital Signs") + ), + codings = listOf( + Coding() + .setSystem("http://loinc.org") + .setCode("29463-7") + .setDisplay("Body weight") + ), + unit = MappedUnit( + code = "kg", + system = "http://unitsofmeasure.org", + unit = "kg" + ), + valueExtractor = { weight.inKilograms }, + periodExtractor = { Date.from(time) to Date.from(time) } + ) + + private fun Observation.addCommonElements() { + this.setStatus(Observation.ObservationStatus.FINAL) + this.setIssued(Date()) + } + + private fun T.createObservation( + categories: List = listOf(), + codings: List, + unit: MappedUnit, + valueExtractor: T.() -> Double, + periodExtractor: T.() -> Pair, + ): Observation { + return Observation().apply { + addCommonElements() + + identifier = listOf(Identifier().apply { + this.setId(this@createObservation.metadata.id) + }) + + category = listOf(CodeableConcept().apply { + categories.forEach { addCoding(it) } + }) + + code = CodeableConcept().apply { + codings.forEach { addCoding(it) } + } + + val (start, end) = periodExtractor() + + if (start == end) { + effective = DateTimeType().apply { + this.value = start + } + } else { + effective = Period().apply { + this.start = start + this.end = end + } + } + + value = Quantity().apply { + this.value = valueExtractor().toBigDecimal() + this.unit = unit.unit + this.code = unit.code + this.system = unit.system + } + } + } +} diff --git a/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/di/RecordToObservationModule.kt b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/di/RecordToObservationModule.kt new file mode 100644 index 000000000..8146df80c --- /dev/null +++ b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/di/RecordToObservationModule.kt @@ -0,0 +1,18 @@ +package edu.stanford.healthconnectonfhir.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import edu.stanford.healthconnectonfhir.RecordToObservationMapper +import edu.stanford.healthconnectonfhir.RecordToObservationMapperImpl + +@Module +@InstallIn(SingletonComponent::class) +abstract class RecordToObservationModule { + + @Binds + abstract fun bindRecordToObservationMapper( + impl: RecordToObservationMapperImpl, + ): RecordToObservationMapper +} diff --git a/modules/healthconnectonfhir/src/test/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperTests.kt b/modules/healthconnectonfhir/src/test/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperTests.kt new file mode 100644 index 000000000..e1df89d0d --- /dev/null +++ b/modules/healthconnectonfhir/src/test/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperTests.kt @@ -0,0 +1,325 @@ +package edu.stanford.healthconnectonfhir + +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BloodPressureRecord +import androidx.health.connect.client.records.BodyFatRecord +import androidx.health.connect.client.records.BodyTemperatureRecord +import androidx.health.connect.client.records.FloorsClimbedRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.HeightRecord +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.RespiratoryRateRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.BloodGlucose +import androidx.health.connect.client.units.Energy +import androidx.health.connect.client.units.Length +import androidx.health.connect.client.units.Mass +import androidx.health.connect.client.units.Percentage +import androidx.health.connect.client.units.Pressure +import androidx.health.connect.client.units.Temperature +import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Period +import org.hl7.fhir.r4.model.Quantity +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset +import java.util.Date + +class RecordToObservationMapperTests { + private val mapper = RecordToObservationMapperImpl() + + @Test + fun `activeCaloriesBurnedRecord toObservation isCorrect`() { + val activeCaloriesBurnedRecord = ActiveCaloriesBurnedRecord( + metadata = Metadata(id = "123456"), + energy = Energy.calories(250.0), + startTime = Instant.parse("2023-05-18T10:15:30.00Z"), + endTime = Instant.parse("2023-05-18T11:15:30.00Z"), + startZoneOffset = ZoneOffset.UTC, + endZoneOffset = ZoneOffset.UTC + ) + + val observation = mapper.map(activeCaloriesBurnedRecord).first() + + assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL) + assertThat(observation.identifier.first().id).isEqualTo("123456") + assertThat(observation.issued.time).isAtMost(Date().time) + assertThat(observation.code.codingFirstRep.code).isEqualTo("41981-2") + assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("activity") + assertThat((observation.effective as Period).start).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z"))) + assertThat((observation.effective as Period).end).isEqualTo(Date.from(Instant.parse("2023-05-18T11:15:30.00Z"))) + assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(250.0) + assertThat((observation.value as Quantity).unit).isEqualTo("kcal") + assertThat((observation.value as Quantity).code).isEqualTo("kcal") + assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org") + } + + @Test + fun `bloodPressureRecord toObservation isCorrect`() { + val bloodPressureRecord = BloodPressureRecord( + metadata = Metadata(id = "123456"), + time = Instant.parse("2023-05-18T10:15:30.00Z"), + systolic = Pressure.millimetersOfMercury(120.0), + diastolic = Pressure.millimetersOfMercury(80.0), + zoneOffset = ZoneOffset.UTC + ) + + val observation = mapper.map(bloodPressureRecord).first() + + assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL) + assertThat(observation.identifier.first().id).isEqualTo("123456") + assertThat(observation.issued.time).isAtMost(Date().time) + assertThat(observation.code.codingFirstRep.code).isEqualTo("85354-9") + assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs") + assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z"))) + assertThat((observation.component[0].value as Quantity).value.toDouble()).isEqualTo(120.0) + assertThat((observation.component[0].value as Quantity).unit).isEqualTo("mmHg") + assertThat((observation.component[0].value as Quantity).code).isEqualTo("mm[Hg]") + assertThat((observation.component[0].value as Quantity).system).isEqualTo("http://unitsofmeasure.org") + assertThat((observation.component[1].value as Quantity).value.toDouble()).isEqualTo(80.0) + assertThat((observation.component[1].value as Quantity).unit).isEqualTo("mmHg") + assertThat((observation.component[1].value as Quantity).code).isEqualTo("mm[Hg]") + assertThat((observation.component[1].value as Quantity).system).isEqualTo("http://unitsofmeasure.org") + } + + @Test + fun `bloodGlucoseRecord toObservation isCorrect`() { + val bloodGlucoseRecord = BloodGlucoseRecord( + metadata = Metadata(id = "123456"), + time = Instant.parse("2023-05-18T10:15:30.00Z"), + level = BloodGlucose.milligramsPerDeciliter(90.0), + zoneOffset = ZoneOffset.UTC + ) + + val observation = mapper.map(bloodGlucoseRecord).first() + + assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL) + assertThat(observation.identifier.first().id).isEqualTo("123456") + assertThat(observation.issued.time).isAtMost(Date().time) + assertThat(observation.code.codingFirstRep.code).isEqualTo("41653-7") + assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z"))) + assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(90.0) + assertThat((observation.value as Quantity).unit).isEqualTo("mg/dL") + assertThat((observation.value as Quantity).code).isEqualTo("mg/dL") + assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org") + } + + @Test + fun `bodyFatRecord toObservation isCorrect`() { + val bodyFatRecord = BodyFatRecord( + metadata = Metadata(id = "123456"), + time = Instant.parse("2023-05-18T10:15:30.00Z"), + percentage = Percentage(10.0), + zoneOffset = ZoneOffset.UTC + ) + + val observation = mapper.map(bodyFatRecord).first() + + assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL) + assertThat(observation.identifier.first().id).isEqualTo("123456") + assertThat(observation.issued.time).isAtMost(Date().time) + assertThat(observation.code.codingFirstRep.code).isEqualTo("41982-0") + assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z"))) + assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(10.0) + assertThat((observation.value as Quantity).unit).isEqualTo("%") + assertThat((observation.value as Quantity).code).isEqualTo("%") + assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org") + } + + @Test + fun `bodyTemperatureRecord toObservation isCorrect`() { + val bodyTemperatureRecord = BodyTemperatureRecord( + metadata = Metadata(id = "123456"), + time = Instant.parse("2023-05-18T10:15:30.00Z"), + temperature = Temperature.celsius(37.5), + zoneOffset = ZoneOffset.UTC + ) + + val observation = mapper.map(bodyTemperatureRecord).first() + + assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL) + assertThat(observation.identifier.first().id).isEqualTo("123456") + assertThat(observation.issued.time).isAtMost(Date().time) + assertThat(observation.code.codingFirstRep.code).isEqualTo("8310-5") + assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs") + assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z"))) + assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(37.5) + assertThat((observation.value as Quantity).unit).isEqualTo("C") + assertThat((observation.value as Quantity).code).isEqualTo("Cel") + assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org") + } + + @Test + fun `heartRateRecord toObservations isCorrect`() { + val heartRateRecord = HeartRateRecord( + samples = listOf( + HeartRateRecord.Sample( + time = Instant.parse("2023-05-18T10:15:30.00Z"), + beatsPerMinute = 72L + ), + HeartRateRecord.Sample( + time = Instant.parse("2023-05-18T10:16:30.00Z"), + beatsPerMinute = 75L + ) + ), + startTime = Instant.parse("2023-05-18T10:15:30.00Z"), + endTime = Instant.parse("2023-05-18T10:17:30.00Z"), + startZoneOffset = ZoneOffset.UTC, + endZoneOffset = ZoneOffset.UTC + ) + + val observations = mapper.map(heartRateRecord) + + observations.forEach { observation -> + assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL) + assertThat(observation.issued.time).isAtMost(Date().time) + assertThat(observation.code.codingFirstRep.code).isEqualTo("8867-4") + assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs") + } + + assertThat((observations[0].effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z"))) + assertThat((observations[0].value as Quantity).value.toDouble()).isEqualTo(72.0) + assertThat((observations[0].value as Quantity).unit).isEqualTo("beats/minute") + + assertThat((observations[1].effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:16:30.00Z"))) + assertThat((observations[1].value as Quantity).value.toDouble()).isEqualTo(75.0) + assertThat((observations[1].value as Quantity).unit).isEqualTo("beats/minute") + assertThat((observations[1].value as Quantity).code).isEqualTo("/min") + assertThat((observations[1].value as Quantity).system).isEqualTo("http://unitsofmeasure.org") + } + + @Test + fun `heightRecord toObservation isCorrect`() { + val heightRecord = HeightRecord( + metadata = Metadata(id = "123456"), + time = Instant.parse("2023-05-18T10:15:30.00Z"), + height = Length.meters(1.5), + zoneOffset = ZoneOffset.UTC + ) + + val observation = mapper.map(heightRecord).first() + + assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL) + assertThat(observation.identifier.first().id).isEqualTo("123456") + assertThat(observation.issued.time).isAtMost(Date().time) + assertThat(observation.code.codingFirstRep.code).isEqualTo("8302-2") + assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs") + assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z"))) + assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(1.5) + assertThat((observation.value as Quantity).unit).isEqualTo("m") + assertThat((observation.value as Quantity).code).isEqualTo("m") + assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org") + } + + @Test + fun `oxygenSaturationRecord toObservation isCorrect`() { + val oxygenSaturationRecord = OxygenSaturationRecord( + metadata = Metadata(id = "123456"), + time = Instant.parse("2023-05-18T10:15:30.00Z"), + percentage = Percentage(99.0), + zoneOffset = ZoneOffset.UTC + ) + + val observation = mapper.map(oxygenSaturationRecord).first() + + assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL) + assertThat(observation.identifier.first().id).isEqualTo("123456") + assertThat(observation.issued.time).isAtMost(Date().time) + assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs") + assertThat(observation.code.codingFirstRep.code).isEqualTo("59408-5") + assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z"))) + assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(99.0) + assertThat((observation.value as Quantity).unit).isEqualTo("%") + assertThat((observation.value as Quantity).code).isEqualTo("%") + assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org") + } + + @Test + fun `respiratoryRate toObservation isCorrect`() { + val respiratoryRateRecord = RespiratoryRateRecord( + metadata = Metadata(id = "123456"), + time = Instant.parse("2023-05-18T10:15:30.00Z"), + rate = 18.0, + zoneOffset = ZoneOffset.UTC + ) + + val observation = mapper.map(respiratoryRateRecord).first() + + assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL) + assertThat(observation.identifier.first().id).isEqualTo("123456") + assertThat(observation.issued.time).isAtMost(Date().time) + assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs") + assertThat(observation.code.codingFirstRep.code).isEqualTo("9279-1") + assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z"))) + assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(18.0) + assertThat((observation.value as Quantity).unit).isEqualTo("breaths/minute") + assertThat((observation.value as Quantity).code).isEqualTo("/min") + assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org") + } + + @Test + fun `stepsRecord toObservation isCorrect`() { + val stepsRecord = StepsRecord( + metadata = Metadata(id = "123456"), + count = 1000, + startTime = Instant.parse("2023-05-18T10:15:30.00Z"), + endTime = Instant.parse("2023-05-18T11:15:30.00Z"), + startZoneOffset = ZoneOffset.UTC, + endZoneOffset = ZoneOffset.UTC + ) + + val observation = mapper.map(stepsRecord).first() + + assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL) + assertThat(observation.identifier.first().id).isEqualTo("123456") + assertThat(observation.issued.time).isAtMost(Date().time) + assertThat(observation.code.codingFirstRep.code).isEqualTo("55423-8") + assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("activity") + assertThat((observation.effective as Period).start).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z"))) + assertThat((observation.effective as Period).end).isEqualTo(Date.from(Instant.parse("2023-05-18T11:15:30.00Z"))) + assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(1000.0) + assertThat((observation.value as Quantity).unit).isEqualTo("steps") + } + + @Test + fun `weightRecord toObservation isCorrect`() { + val weightRecord = WeightRecord( + metadata = Metadata(id = "123456"), + weight = Mass.kilograms(75.0), + time = Instant.parse("2023-05-18T10:15:30.00Z"), + zoneOffset = ZoneOffset.UTC + ) + + val observation = mapper.map(weightRecord).first() + + assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL) + assertThat(observation.identifier.first().id).isEqualTo("123456") + assertThat(observation.issued.time).isAtMost(Date().time) + assertThat(observation.code.codingFirstRep.code).isEqualTo("29463-7") + assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs") + assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z"))) + assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(75.0) + assertThat((observation.value as Quantity).unit).isEqualTo("kg") + assertThat((observation.value as Quantity).code).isEqualTo("kg") + assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org") + } + + @Test(expected = IllegalArgumentException::class) + fun `map throws IllegalArgumentException for unsupported record type`() { + val record = FloorsClimbedRecord( + metadata = Metadata(id = "123456"), + startTime = Instant.parse("2023-05-18T10:15:30.00Z"), + endTime = Instant.parse("2023-05-18T11:15:30.00Z"), + startZoneOffset = ZoneOffset.UTC, + endZoneOffset = ZoneOffset.UTC, + floors = 2.0 + ) + + mapper.map(record) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0d228923a..c0875daed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,6 +36,7 @@ include(":core:logging") include(":core:testing") include(":core:utils") include(":modules:contact") +include(":modules:healthconnectonfhir") include(":modules:onboarding") include(":modules:account") include(":core:navigation")