1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.server.healthconnect.storage.datatypehelpers; 18 19 import static com.android.server.healthconnect.storage.datatypehelpers.BasalMetabolicRateRecordHelper.BASAL_METABOLIC_RATE_COLUMN_NAME; 20 import static com.android.server.healthconnect.storage.datatypehelpers.BasalMetabolicRateRecordHelper.BASAL_METABOLIC_RATE_RECORD_TABLE_NAME; 21 import static com.android.server.healthconnect.storage.datatypehelpers.HeightRecordHelper.HEIGHT_COLUMN_NAME; 22 import static com.android.server.healthconnect.storage.datatypehelpers.HeightRecordHelper.HEIGHT_RECORD_TABLE_NAME; 23 import static com.android.server.healthconnect.storage.datatypehelpers.LeanBodyMassRecordHelper.LEAN_BODY_MASS_RECORD_TABLE_NAME; 24 import static com.android.server.healthconnect.storage.datatypehelpers.LeanBodyMassRecordHelper.MASS_COLUMN_NAME; 25 import static com.android.server.healthconnect.storage.datatypehelpers.WeightRecordHelper.WEIGHT_COLUMN_NAME; 26 import static com.android.server.healthconnect.storage.datatypehelpers.WeightRecordHelper.WEIGHT_RECORD_TABLE_NAME; 27 import static com.android.server.healthconnect.storage.utils.WhereClauses.LogicalOperator.AND; 28 29 import android.database.Cursor; 30 import android.health.connect.Constants; 31 import android.health.connect.datatypes.BasalMetabolicRateRecord; 32 import android.util.Pair; 33 import android.util.Slog; 34 35 import com.android.server.healthconnect.storage.TransactionManager; 36 import com.android.server.healthconnect.storage.request.ReadTableRequest; 37 import com.android.server.healthconnect.storage.utils.OrderByClause; 38 import com.android.server.healthconnect.storage.utils.StorageUtils; 39 import com.android.server.healthconnect.storage.utils.WhereClauses; 40 41 import java.time.Duration; 42 import java.util.List; 43 import java.util.Objects; 44 45 /** 46 * Helper class to Derive BasalCaloriesTotal aggregate 47 * 48 * @hide 49 */ 50 public final class DeriveBasalCaloriesBurnedHelper { 51 private static final int KCAL_TO_CAL = 1000; 52 private static final double GMS_IN_KG = 1000.0; 53 private static final double WATT_TO_CAL_PER_HR = 860; 54 private static final int HOURS_PER_DAY = 24; 55 private static final double DEFAULT_WEIGHT_IN_GMS = 73000; 56 private static final double DEFAULT_HEIGHT_IN_METERS = 1.7; 57 private static final int DEFAULT_GENDER_CONSTANT = -78; 58 private static final String TAG = "DeriveBasalCalories"; 59 private final Cursor mCursor; 60 private final String mColumnName; 61 private final TransactionManager mTransactionManager; 62 private double mRateOfEnergyBurntInWatts = 0; 63 private String mTimeColumnName; 64 65 @SuppressWarnings("GoodTime") // constant age represented by primitive 66 private static final int DEFAULT_AGE = 30; 67 DeriveBasalCaloriesBurnedHelper( Cursor cursor, String columnName, String timeColumnName, TransactionManager transactionManager)68 public DeriveBasalCaloriesBurnedHelper( 69 Cursor cursor, 70 String columnName, 71 String timeColumnName, 72 TransactionManager transactionManager) { 73 Objects.requireNonNull(cursor); 74 Objects.requireNonNull(columnName); 75 Objects.requireNonNull(timeColumnName); 76 mCursor = cursor; 77 mColumnName = columnName; 78 mTimeColumnName = timeColumnName; 79 mTransactionManager = transactionManager; 80 } 81 82 /** 83 * Calculates and returns aggregate of total basal calories burned from table {@link 84 * BasalMetabolicRateRecord} for the interval. 85 */ getBasalCaloriesBurned(long intervalStartTime, long intervalEndTime)86 public double getBasalCaloriesBurned(long intervalStartTime, long intervalEndTime) { 87 if (intervalStartTime >= intervalEndTime) { 88 return 0; 89 } 90 91 double currentGroupTotal = 0; 92 // calculate aggregate for current interval between start and end time by iterating 93 // cursor until current time is inside current group interval 94 if (mCursor.getCount() == 0) { 95 return derivedBasalCaloriesViaReadBack(intervalStartTime, intervalEndTime); 96 } 97 98 long lastItemTime = -1; 99 while (mCursor.moveToNext()) { 100 long time = StorageUtils.getCursorLong(mCursor, mTimeColumnName); 101 102 if (lastItemTime == -1) { 103 if (time > intervalStartTime && mRateOfEnergyBurntInWatts == 0) { 104 if (time > intervalEndTime) { 105 mCursor.moveToPrevious(); 106 return derivedBasalCaloriesViaReadBack(intervalStartTime, intervalEndTime); 107 } 108 109 currentGroupTotal += derivedBasalCaloriesViaReadBack(intervalStartTime, time); 110 lastItemTime = time; 111 } else { 112 lastItemTime = intervalStartTime; 113 } 114 115 mRateOfEnergyBurntInWatts = StorageUtils.getCursorDouble(mCursor, mColumnName); 116 continue; 117 } 118 119 if (time >= intervalEndTime) { 120 mCursor.moveToPrevious(); 121 break; 122 } 123 124 currentGroupTotal += 125 getCurrentIntervalEnergy(mRateOfEnergyBurntInWatts, lastItemTime, time); 126 mRateOfEnergyBurntInWatts = StorageUtils.getCursorDouble(mCursor, mColumnName); 127 lastItemTime = time; 128 } 129 130 if (lastItemTime == -1) { 131 currentGroupTotal += 132 getCurrentIntervalEnergy( 133 mRateOfEnergyBurntInWatts, intervalStartTime, intervalEndTime); 134 } else if (lastItemTime < intervalEndTime) { 135 currentGroupTotal += 136 getCurrentIntervalEnergy( 137 mRateOfEnergyBurntInWatts, lastItemTime, intervalEndTime); 138 } 139 140 return currentGroupTotal; 141 } 142 derivedBasalCaloriesViaReadBack(long intervalStartTime, long intervalEndTime)143 private double derivedBasalCaloriesViaReadBack(long intervalStartTime, long intervalEndTime) { 144 if (mRateOfEnergyBurntInWatts != 0) { 145 return getCurrentIntervalEnergy( 146 mRateOfEnergyBurntInWatts, intervalStartTime, intervalEndTime); 147 } 148 149 try (Cursor cursor = 150 mTransactionManager.read( 151 new ReadTableRequest(BASAL_METABOLIC_RATE_RECORD_TABLE_NAME) 152 .setColumnNames(List.of(BASAL_METABOLIC_RATE_COLUMN_NAME)) 153 .setWhereClause( 154 new WhereClauses(AND) 155 .addWhereLessThanOrEqualClause( 156 mTimeColumnName, intervalStartTime)) 157 .setLimit(1) 158 .setOrderBy( 159 new OrderByClause() 160 .addOrderByClause(mTimeColumnName, false)))) { 161 if (cursor.getCount() == 0) { 162 // No data found, fallback to LBM 163 return derivedBasalCaloriesBurnedFromLeanBodyMass( 164 intervalStartTime, intervalEndTime); 165 } 166 cursor.moveToNext(); 167 mRateOfEnergyBurntInWatts = 168 StorageUtils.getCursorDouble(cursor, BASAL_METABOLIC_RATE_COLUMN_NAME); 169 return getCurrentIntervalEnergy( 170 mRateOfEnergyBurntInWatts, intervalStartTime, intervalEndTime); 171 } 172 } 173 derivedBasalCaloriesBurnedFromLeanBodyMass( long intervalStartTime, long intervalEndTime)174 private double derivedBasalCaloriesBurnedFromLeanBodyMass( 175 long intervalStartTime, long intervalEndTime) { 176 double totalCalories = 0; 177 try (Cursor lbmCursor = getLeanBodyMassCursor(intervalStartTime, intervalEndTime)) { 178 if (lbmCursor.getCount() == 0) { 179 // No data found, fallback to profile data 180 return derivedBasalCaloriesBurnedFromProfile(intervalStartTime, intervalEndTime); 181 } 182 183 long lastReadTime = -1; 184 double bmrFromLbmInCaloriesPerDay = 0; 185 while (lbmCursor.moveToNext()) { 186 double mass = StorageUtils.getCursorDouble(lbmCursor, MASS_COLUMN_NAME); 187 long time = StorageUtils.getCursorLong(lbmCursor, mTimeColumnName); 188 if (lastReadTime == -1) { 189 // Derive calories from profile for start time to first entry time, if required 190 if (time > intervalStartTime) { 191 totalCalories += 192 derivedBasalCaloriesBurnedFromProfile(intervalStartTime, time); 193 lastReadTime = time; 194 } else { 195 lastReadTime = intervalStartTime; 196 } 197 bmrFromLbmInCaloriesPerDay = getBmrFromLbmInCaloriesPerDay(mass); 198 continue; 199 } 200 201 totalCalories += getCalories(bmrFromLbmInCaloriesPerDay, lastReadTime, time); 202 203 bmrFromLbmInCaloriesPerDay = getBmrFromLbmInCaloriesPerDay(mass); 204 lastReadTime = time; 205 } 206 207 if (lastReadTime < intervalEndTime) { 208 totalCalories += 209 getCalories(bmrFromLbmInCaloriesPerDay, lastReadTime, intervalEndTime); 210 } 211 } 212 213 return totalCalories; 214 } 215 getBmrFromLbmInCaloriesPerDay(double massInGms)216 private double getBmrFromLbmInCaloriesPerDay(double massInGms) { 217 return (370 + 21.6 * (massInGms / GMS_IN_KG)) * KCAL_TO_CAL; 218 } 219 220 // TODO(b/302521219): Restructure this derivation logic derivedBasalCaloriesBurnedFromProfile( long intervalStartTime, long intervalEndTime)221 private double derivedBasalCaloriesBurnedFromProfile( 222 long intervalStartTime, long intervalEndTime) { 223 double caloriesFromProfile = 0; 224 try (Cursor heightCursor = getHeightCursor(intervalStartTime, intervalEndTime); 225 Cursor weightCursor = getWeightCursor(intervalStartTime, intervalEndTime)) { 226 if (heightCursor.getCount() == 0 && weightCursor.getCount() == 0) { 227 return getCaloriesFromHeightAndWeight( 228 DEFAULT_HEIGHT_IN_METERS, 229 DEFAULT_WEIGHT_IN_GMS, 230 intervalStartTime, 231 intervalEndTime); 232 } 233 234 boolean hasHeight = heightCursor.moveToNext(); 235 boolean hasWeight = weightCursor.moveToNext(); 236 long lastTimeUsed = -1; 237 double height = DEFAULT_HEIGHT_IN_METERS; 238 double weight = DEFAULT_WEIGHT_IN_GMS; 239 240 long heightTime = Long.MAX_VALUE; 241 long weightTime = Long.MAX_VALUE; 242 while (hasHeight || hasWeight) { 243 if (hasHeight) { 244 heightTime = StorageUtils.getCursorLong(heightCursor, mTimeColumnName); 245 } 246 247 if (hasWeight) { 248 weightTime = StorageUtils.getCursorLong(weightCursor, mTimeColumnName); 249 } 250 251 if (lastTimeUsed < intervalStartTime) { 252 lastTimeUsed = Math.min(heightTime, weightTime); 253 if (lastTimeUsed > intervalStartTime) { 254 caloriesFromProfile += 255 getCaloriesFromHeightAndWeight( 256 height, weight, intervalStartTime, lastTimeUsed); 257 } 258 } else { 259 long time = Math.min(heightTime, weightTime); 260 caloriesFromProfile += 261 getCaloriesFromHeightAndWeight(height, weight, lastTimeUsed, time); 262 lastTimeUsed = time; 263 } 264 265 // Move the cursor one by one to calculate BMR as accurately as possible. 266 if ((heightTime < weightTime) && hasHeight) { 267 height = StorageUtils.getCursorDouble(heightCursor, HEIGHT_COLUMN_NAME); 268 hasHeight = heightCursor.moveToNext(); 269 } else if ((weightTime < heightTime) && hasWeight) { 270 weight = StorageUtils.getCursorDouble(weightCursor, WEIGHT_COLUMN_NAME); 271 hasWeight = weightCursor.moveToNext(); 272 } else { 273 if (hasWeight) { 274 weight = StorageUtils.getCursorDouble(weightCursor, WEIGHT_COLUMN_NAME); 275 hasWeight = weightCursor.moveToNext(); 276 } 277 278 if (hasHeight) { 279 height = StorageUtils.getCursorDouble(heightCursor, HEIGHT_COLUMN_NAME); 280 hasHeight = heightCursor.moveToNext(); 281 } 282 } 283 } 284 285 if (lastTimeUsed < intervalEndTime) { 286 caloriesFromProfile += 287 getCaloriesFromHeightAndWeight( 288 height, 289 weight, 290 // Snap to startTime in case the last-used record is still before 291 // the startTime of the interval 292 Math.max(intervalStartTime, lastTimeUsed), 293 intervalEndTime); 294 } 295 } 296 297 return caloriesFromProfile; 298 } 299 getLeanBodyMassCursor(long intervalStartTime, long intervalEndTime)300 private Cursor getLeanBodyMassCursor(long intervalStartTime, long intervalEndTime) { 301 return getReadCursorForDerivingBMR( 302 intervalStartTime, 303 intervalEndTime, 304 LEAN_BODY_MASS_RECORD_TABLE_NAME, 305 MASS_COLUMN_NAME); 306 } 307 getHeightCursor(long intervalStartTime, long intervalEndTime)308 private Cursor getHeightCursor(long intervalStartTime, long intervalEndTime) { 309 return getReadCursorForDerivingBMR( 310 intervalStartTime, intervalEndTime, HEIGHT_RECORD_TABLE_NAME, HEIGHT_COLUMN_NAME); 311 } 312 getWeightCursor(long intervalStartTime, long intervalEndTime)313 private Cursor getWeightCursor(long intervalStartTime, long intervalEndTime) { 314 return getReadCursorForDerivingBMR( 315 intervalStartTime, intervalEndTime, WEIGHT_RECORD_TABLE_NAME, WEIGHT_COLUMN_NAME); 316 } 317 getReadCursorForDerivingBMR( long intervalStartTime, long intervalEndTime, String tableName, String colName)318 private Cursor getReadCursorForDerivingBMR( 319 long intervalStartTime, long intervalEndTime, String tableName, String colName) { 320 return mTransactionManager.read( 321 new ReadTableRequest(tableName) 322 .setColumnNames(List.of(colName, mTimeColumnName)) 323 .setWhereClause( 324 new WhereClauses(AND) 325 .addWhereBetweenTimeClause( 326 mTimeColumnName, 327 intervalStartTime, 328 intervalEndTime)) 329 .setOrderBy(new OrderByClause().addOrderByClause(mTimeColumnName, true)) 330 .setUnionReadRequests( 331 List.of( 332 new ReadTableRequest(tableName) 333 .setColumnNames(List.of(colName, mTimeColumnName)) 334 .setWhereClause( 335 new WhereClauses(AND) 336 .addWhereLessThanOrEqualClause( 337 mTimeColumnName, 338 intervalStartTime)) 339 .setLimit(1) 340 .setOrderBy( 341 new OrderByClause() 342 .addOrderByClause( 343 mTimeColumnName, false))))); 344 } 345 346 /** 347 * Calculates and returns an array of aggregate of total basal calories burned from table {@link 348 * BasalMetabolicRateRecord} for group of intervals. 349 */ getBasalCaloriesBurned(List<Pair<Long, Long>> groupIntervalList)350 public double[] getBasalCaloriesBurned(List<Pair<Long, Long>> groupIntervalList) { 351 double[] basalCaloriesBurned = new double[groupIntervalList.size()]; 352 for (int group = 0; group < groupIntervalList.size(); group++) { 353 basalCaloriesBurned[group] = 354 getBasalCaloriesBurned( 355 groupIntervalList.get(group).first, 356 groupIntervalList.get(group).second); 357 } 358 return basalCaloriesBurned; 359 } 360 getCaloriesFromHeightAndWeight( double height, double weight, long startTime, long endTime)361 private double getCaloriesFromHeightAndWeight( 362 double height, double weight, long startTime, long endTime) { 363 if (Constants.DEBUG) { 364 Slog.d( 365 TAG, 366 "Calculating calories from profile start time: " 367 + startTime 368 + " end time: " 369 + endTime 370 + " height: " 371 + height 372 + " weight: " 373 + weight); 374 } 375 376 double bmrInCaloriesPerDay = 377 (10 * (weight / GMS_IN_KG) 378 + 6.25 * height * 100 379 - 5 * DEFAULT_AGE 380 + DEFAULT_GENDER_CONSTANT) 381 * KCAL_TO_CAL; 382 return bmrInCaloriesPerDay 383 * ((double) (endTime - startTime) / Duration.ofDays(1).toMillis()); 384 } 385 getCalories(double bmrInCaloriesPerDay, long startTime, long endTime)386 private double getCalories(double bmrInCaloriesPerDay, long startTime, long endTime) { 387 if (Constants.DEBUG) { 388 Slog.d( 389 TAG, 390 "Calculating calories from BMR start time: " 391 + startTime 392 + " end time: " 393 + endTime 394 + " bmrInCaloriesPerDay: " 395 + bmrInCaloriesPerDay); 396 } 397 398 return bmrInCaloriesPerDay 399 * ((double) (endTime - startTime) / Duration.ofDays(1).toMillis()); 400 } 401 getCurrentIntervalEnergy( double rateOfEnergyBurntInWatts, long startTime, long endTime)402 private double getCurrentIntervalEnergy( 403 double rateOfEnergyBurntInWatts, long startTime, long endTime) { 404 if (Constants.DEBUG) { 405 Slog.d( 406 TAG, 407 "Calculating calories from LBM start time: " 408 + startTime 409 + " end time: " 410 + endTime 411 + " bmrInCaloriesPerDay: " 412 + rateOfEnergyBurntInWatts); 413 } 414 return getCalPerDay(rateOfEnergyBurntInWatts) 415 * ((double) (endTime - startTime) / Duration.ofDays(1).toMillis()); 416 } 417 getCalPerDay(double rateOfEnergyBurntInWatt)418 private double getCalPerDay(double rateOfEnergyBurntInWatt) { 419 return rateOfEnergyBurntInWatt * HOURS_PER_DAY * WATT_TO_CAL_PER_HR; 420 } 421 } 422