• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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