Skip to content

Commit

Permalink
Adding Time Index To HealthKit Sample Uploads (#34)
Browse files Browse the repository at this point in the history
# *Adding Time Index To HealthKit Sample Uploads*

## ♻️ Current situation & Problem
As we are transitioning the backend to an architecture that doesn't
depend on cloud functions and periodic featurization, we are working on
efficient data fetching queries in Firebase. Many HealthKit data types
are associated with a time _range_, not a timestamp. This complicates
Firebase queries since queries like
```
collection.where(filter=("timeRange.start", "<=", queryEnd)
          .where(filter=FieldFilter("timeRange.end", ">=", queryStart))
```
are not permitted due to inequality filters on multiple properties. 

Instead, I am proposing adding the following fields to each HealthKit
upload:
```
time.range: bool // whether the sample is a time range or time stamp type
                 // if time.range = false, not .end properties are set
time.year.start, time.year.end: int
time.month.start, time.month.end: int
time.day.start, time.day.end: int
time.hour.start, time.hour.end: int
time.minute.start, time.minute.end: int
time.second.start, time.second.end: int
time.dayMin.start, time.dayMin.end: int // how many total minutes have elapsed, between [0, 1439]
time.15minBucket.start, time.15minBucket.end // which 15 min bucket the sample falls into, between [0,95]
```
in addition, we will also have ranges for each time index component,
such as
```
time.15minBucket.range = [45, 46, 47] // sample falls between 11:15-12:00
```
which will allow us to make efficient Firebase [array membership
queries](https://firebase.google.com/docs/firestore/query-data/queries#array_membership)

## ⚙️ Release Notes 
* Added a function `constructTimeIndex` in a new
`PrismaStandard+TimeIndex.swift` that computes the necessary time fields

## 📚 Documentation
N/A


## ✅ Testing
N/A

## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
mjoerke authored Mar 9, 2024
1 parent b549eed commit 01bd84d
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 28 deletions.
8 changes: 6 additions & 2 deletions Prisma.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@
A9D83F962B083794000D0C78 /* SpeziFirebaseAccountStorage in Frameworks */ = {isa = PBXBuildFile; productRef = A9D83F952B083794000D0C78 /* SpeziFirebaseAccountStorage */; };
A9DFE8A92ABE551400428242 /* AccountButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DFE8A82ABE551400428242 /* AccountButton.swift */; };
A9FE7AD02AA39BAB0077B045 /* AccountSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */; };
E4C766262B72D50500C1DEDA /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C766252B72D50500C1DEDA /* WebView.swift */; };
AC69903E2B6C5A2F00D92970 /* PrivacyControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC69903D2B6C5A2F00D92970 /* PrivacyControls.swift */; };
AC6990402B6C627100D92970 /* ToggleTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC69903F2B6C627100D92970 /* ToggleTestView.swift */; };
E4C766262B72D50500C1DEDA /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C766252B72D50500C1DEDA /* WebView.swift */; };
F83B7CBE2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83B7CBD2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift */; };
F8AF6F9A2B5F2B1A0011C32D /* AppIcon-NoBG.png in Resources */ = {isa = PBXBuildFile; fileRef = F8AF6F992B5F2B1A0011C32D /* AppIcon-NoBG.png */; };
F8AF6F9F2B5F35400011C32D /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AF6F9E2B5F35400011C32D /* ChatView.swift */; };
F8AF6FA52B5F3AE70011C32D /* EventContextCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AF6FA42B5F3AE70011C32D /* EventContextCard.swift */; };
Expand Down Expand Up @@ -152,9 +153,10 @@
A9720E422ABB68CC00872D23 /* AccountSetupHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSetupHeader.swift; sourceTree = "<group>"; };
A9DFE8A82ABE551400428242 /* AccountButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountButton.swift; sourceTree = "<group>"; };
A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSheet.swift; sourceTree = "<group>"; };
E4C766252B72D50500C1DEDA /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = "<group>"; };
AC69903D2B6C5A2F00D92970 /* PrivacyControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyControls.swift; sourceTree = "<group>"; };
AC69903F2B6C627100D92970 /* ToggleTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleTestView.swift; sourceTree = "<group>"; };
E4C766252B72D50500C1DEDA /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = "<group>"; };
F83B7CBD2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrismaStandard+TimeIndex.swift"; sourceTree = "<group>"; };
F8AF6F992B5F2B1A0011C32D /* AppIcon-NoBG.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon-NoBG.png"; sourceTree = "<group>"; };
F8AF6F9E2B5F35400011C32D /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
F8AF6FA42B5F3AE70011C32D /* EventContextCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventContextCard.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -441,6 +443,7 @@
F8AF6FB82B5F72650011C32D /* PrismaStandard+HealthKit.swift */,
F8AF6FB32B5F6EDC0011C32D /* PrismaModule.swift */,
F8AF6FB52B5F71460011C32D /* PrismaStandard+Extension.swift */,
F83B7CBD2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift */,
F8AF6FBB2B5F74EA0011C32D /* PrismaStandard+Questionnaire.swift */,
);
path = Standard;
Expand Down Expand Up @@ -678,6 +681,7 @@
2FE5DC5029EDD7FA004B9AB4 /* EventContextView.swift in Sources */,
F8AF6FB62B5F71460011C32D /* PrismaStandard+Extension.swift in Sources */,
2F4E23832989D51F0013F3D9 /* PrismaTestingSetup.swift in Sources */,
F83B7CBE2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift in Sources */,
2FE5DC5329EDD7FA004B9AB4 /* Bundle+Questionnaire.swift in Sources */,
2FE5DC5129EDD7FA004B9AB4 /* PrismaTaskContext.swift in Sources */,
F8AF6FBC2B5F74EA0011C32D /* PrismaStandard+Questionnaire.swift in Sources */,
Expand Down
34 changes: 8 additions & 26 deletions Prisma/Standard/PrismaStandard+HealthKit.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import FirebaseFirestore
import HealthKitOnFHIR
//
// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project
//
Expand All @@ -8,29 +6,12 @@ import HealthKitOnFHIR
// SPDX-License-Identifier: MIT
//

import FirebaseFirestore
import HealthKitOnFHIR
import ModelsR4
import SpeziFirestore
import SpeziHealthKit

/*
HKQuantityType(.vo2Max),
HKQuantityType(.heartRate),
HKQuantityType(.restingHeartRate),
HKQuantityType(.oxygenSaturation),
HKQuantityType(.respiratoryRate),
HKQuantityType(.walkingHeartRateAverage)
var includeVo2Max = true
var includeHeartRate = true
var includeRestingHeartRate = true
var includeOxygenSaturation = true
var includeRespiratoryRate = true
var includeWalkingHRAverage = true
*/


extension PrismaStandard {
func getSampleIdentifier(sample: HKSample) -> String? {
switch sample {
Expand All @@ -52,10 +33,10 @@ extension PrismaStandard {
func add(sample: HKSample) async {
let identifier: String
if let id = getSampleIdentifier(sample: sample) {
print("Sample identifier: \(id)")
// print("Sample identifier: \(id)")
identifier = id
} else {
print("Unknown sample type")
print("Failed to upload HealtHkit sample. Unknown sample type: \(sample)")
return
}

Expand Down Expand Up @@ -84,9 +65,9 @@ extension PrismaStandard {


// convert the startDate of the HKSample to local time
let startDatetime = sample.startDate
let effectiveTimestamp = startDatetime.localISOFormat()
let endDatetime = sample.endDate.localISOFormat()
let timeIndex = constructTimeIndex(startDate: sample.startDate, endDate: sample.endDate)
let effectiveTimestamp = sample.startDate.localISOFormat()


let path: String
// path = HEALTH_KIT_PATH/raw/YYYY-MM-DDThh:mm:ss.mss
Expand All @@ -112,6 +93,7 @@ extension PrismaStandard {
let encoder = FirebaseFirestore.Firestore.Encoder()
var firestoreResource = try encoder.encode(resource)
firestoreResource["device"] = deviceName
firestoreResource["time"] = timeIndex
try await Firestore.firestore().document(path).setData(firestoreResource)
} catch {
print("Failed to set data in Firestore: \(error.localizedDescription)")
Expand Down
121 changes: 121 additions & 0 deletions Prisma/Standard/PrismaStandard+TimeIndex.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//
// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//
// Created by Matthew Jörke on 2/28/24.
//

import Foundation

func constructTimeIndex(startDate: Date, endDate: Date) -> [String: Any?] {
let calendar = Calendar.current
let startComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .timeZone], from: startDate)
let endComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .timeZone], from: endDate)
let isRange = startDate != endDate

var timeIndex: [String: Any?] = [
"range": isRange,
"timezone": startComponents.timeZone,
"datetime.start": startDate.localISOFormat(),
"datetime.end": endDate.localISOFormat()
]

addTimeIndexComponents(&timeIndex, dateComponents: startComponents, suffix: ".start")

if isRange {
// only write end date and range if the sample is a range type
addTimeIndexComponents(&timeIndex, dateComponents: endComponents, suffix: ".end")
addTimeIndexRangeComponents(&timeIndex, startComponents: startComponents, endComponents: endComponents)
}

return timeIndex
}

func addTimeIndexComponents(_ timeIndex: inout [String: Any?], dateComponents: DateComponents, suffix: String) {
timeIndex["year" + suffix] = dateComponents.year
timeIndex["month" + suffix] = dateComponents.month
timeIndex["day" + suffix] = dateComponents.day
timeIndex["hour" + suffix] = dateComponents.hour
timeIndex["minute" + suffix] = dateComponents.minute
timeIndex["second" + suffix] = dateComponents.second
timeIndex["dayMinute" + suffix] = calculateDayMinute(hour: dateComponents.hour, minute: dateComponents.minute)
timeIndex["15minBucket" + suffix] = calculate15MinBucket(hour: dateComponents.hour, minute: dateComponents.minute)
}

func addTimeIndexRangeComponents(_ timeIndex: inout [String: Any?], startComponents: DateComponents, endComponents: DateComponents) {
timeIndex["year.range"] = getRange(
start: startComponents.year,
end: endComponents.year,
maxValue: Int.max
)
timeIndex["month.range"] = getRange(
start: startComponents.month,
end: endComponents.month,
maxValue: 12,
startValue: 1 // months are 1-indexed
)
timeIndex["day.range"] = getRange(
start: startComponents.day,
end: endComponents.day,
maxValue: daysInMonth(month: startComponents.month, year: startComponents.year),
startValue: 1 // days are 1-indexed
)
timeIndex["hour.range"] = getRange(
start: startComponents.hour,
end: endComponents.hour,
maxValue: 23
)
timeIndex["dayMinute.range"] = getRange(
start: calculateDayMinute(hour: startComponents.hour, minute: startComponents.minute),
end: calculateDayMinute(hour: endComponents.hour, minute: endComponents.minute),
maxValue: 1439
)
timeIndex["15minBucket.range"] = getRange(
start: calculate15MinBucket(hour: startComponents.hour, minute: startComponents.minute),
end: calculate15MinBucket(hour: endComponents.hour, minute: endComponents.minute),
maxValue: 95
)

// Minute and second ranges are not likely to be accurate since they often will fill the whole range.
// We will also never query on individual minutes or seconds worth of data.
}

// swiftlint:disable discouraged_optional_collection
func getRange(start: Int?, end: Int?, maxValue: Int?, startValue: Int = 0) -> [Int]? {
guard let startInt = start, let endInt = end, let maxValueInt = maxValue else {
return nil
}

if startInt <= endInt {
return Array(startInt...endInt)
} else {
return Array(startInt...maxValueInt) + Array(startValue...endInt)
}
}

func daysInMonth(month: Int?, year: Int?) -> Int? {
let dateComponents = DateComponents(year: year, month: month)
let calendar = Calendar.current
guard let date = calendar.date(from: dateComponents),
let range = calendar.range(of: .day, in: .month, for: date) else {
return nil // Provide a default value in case of nil
}
return range.count
}

func calculateDayMinute(hour: Int?, minute: Int?) -> Int? {
guard let hour = hour, let minute = minute else {
return nil
}
return hour * 60 + minute
}

func calculate15MinBucket(hour: Int?, minute: Int?) -> Int? {
guard let hour = hour, let minute = minute else {
return nil
}
return hour * 4 + minute / 15
}

0 comments on commit 01bd84d

Please sign in to comment.