1 /*
2  * Copyright (C) 2024 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 android.healthconnect.cts.aggregation;
18 
19 import static android.health.connect.datatypes.TotalCaloriesBurnedRecord.ENERGY_TOTAL;
20 import static android.healthconnect.cts.aggregation.DataFactory.getActiveCaloriesBurnedRecord;
21 import static android.healthconnect.cts.aggregation.DataFactory.getBasalMetabolicRateRecord;
22 import static android.healthconnect.cts.aggregation.DataFactory.getBaseHeightRecord;
23 import static android.healthconnect.cts.aggregation.DataFactory.getBaseLeanBodyMassRecord;
24 import static android.healthconnect.cts.aggregation.DataFactory.getBaseWeightRecord;
25 import static android.healthconnect.cts.aggregation.DataFactory.getTimeFilter;
26 import static android.healthconnect.cts.aggregation.Utils.assertEnergyWithTolerance;
27 import static android.healthconnect.cts.utils.DataFactory.getDataOrigin;
28 import static android.healthconnect.cts.utils.DataFactory.getEmptyMetadata;
29 import static android.healthconnect.cts.utils.TestUtils.deleteAllStagedRemoteData;
30 import static android.healthconnect.cts.utils.TestUtils.getAggregateResponse;
31 import static android.healthconnect.cts.utils.TestUtils.getAggregateResponseGroupByDuration;
32 import static android.healthconnect.cts.utils.TestUtils.getAggregateResponseGroupByPeriod;
33 import static android.healthconnect.cts.utils.TestUtils.insertRecord;
34 import static android.healthconnect.cts.utils.TestUtils.insertRecords;
35 import static android.healthconnect.cts.utils.TestUtils.setupAggregation;
36 
37 import static com.google.common.truth.Truth.assertThat;
38 
39 import static java.time.Instant.EPOCH;
40 import static java.time.temporal.ChronoUnit.DAYS;
41 import static java.time.temporal.ChronoUnit.HOURS;
42 import static java.time.temporal.ChronoUnit.MINUTES;
43 
44 import android.health.connect.AggregateRecordsGroupedByDurationResponse;
45 import android.health.connect.AggregateRecordsRequest;
46 import android.health.connect.AggregateRecordsResponse;
47 import android.health.connect.HealthDataCategory;
48 import android.health.connect.LocalTimeRangeFilter;
49 import android.health.connect.TimeInstantRangeFilter;
50 import android.health.connect.datatypes.ActiveCaloriesBurnedRecord;
51 import android.health.connect.datatypes.TotalCaloriesBurnedRecord;
52 import android.health.connect.datatypes.units.Energy;
53 import android.healthconnect.cts.utils.AssumptionCheckerRule;
54 import android.healthconnect.cts.utils.TestUtils;
55 
56 import androidx.test.core.app.ApplicationProvider;
57 
58 import org.junit.After;
59 import org.junit.Before;
60 import org.junit.Rule;
61 import org.junit.Test;
62 
63 import java.time.Duration;
64 import java.time.Instant;
65 import java.time.LocalDateTime;
66 import java.time.Period;
67 import java.time.ZoneOffset;
68 import java.util.Arrays;
69 import java.util.List;
70 
71 public final class TotalCaloriesAggregationTest {
72     private static final double DEFAULT_BASAL_CALORIES_PER_DAY =
73             getBasalCaloriesPerDay(/* weightKg= */ 73, /* heightCm= */ 170);
74 
75     private final String mPackageName =
76             ApplicationProvider.getApplicationContext().getPackageName();
77 
78     @Rule
79     public AssumptionCheckerRule mSupportedHardwareRule =
80             new AssumptionCheckerRule(
81                     TestUtils::isHardwareSupported, "Tests should run on supported hardware only.");
82 
83     @Before
setUp()84     public void setUp() throws InterruptedException {
85         deleteAllStagedRemoteData();
86         setupAggregation(mPackageName, HealthDataCategory.ACTIVITY);
87     }
88 
89     @After
tearDown()90     public void tearDown() {
91         deleteAllStagedRemoteData();
92     }
93 
94     @Test
totalCaloriesBurned_derivedFromDefaultBasalCalories()95     public void totalCaloriesBurned_derivedFromDefaultBasalCalories() throws Exception {
96         Instant now = Instant.now();
97 
98         AggregateRecordsRequest<Energy> request =
99                 new AggregateRecordsRequest.Builder<Energy>(getTimeFilter(now.minus(1, DAYS), now))
100                         .addAggregationType(ENERGY_TOTAL)
101                         .build();
102         AggregateRecordsResponse<Energy> response = getAggregateResponse(request);
103 
104         assertEnergyWithTolerance(response.get(ENERGY_TOTAL), DEFAULT_BASAL_CALORIES_PER_DAY);
105     }
106 
107     @Test
totalCaloriesBurned_derivedFromWeightAndHeight()108     public void totalCaloriesBurned_derivedFromWeightAndHeight() throws Exception {
109         Instant now = Instant.now();
110         double heightCm = 180;
111         double weightKg = 85;
112         insertRecords(
113                 List.of(
114                         getBaseHeightRecord(EPOCH, heightCm / 100),
115                         getBaseWeightRecord(EPOCH, weightKg)));
116 
117         AggregateRecordsRequest<Energy> request =
118                 new AggregateRecordsRequest.Builder<Energy>(getTimeFilter(now.minus(1, DAYS), now))
119                         .addAggregationType(ENERGY_TOTAL)
120                         .build();
121         AggregateRecordsResponse<Energy> response = getAggregateResponse(request);
122 
123         assertEnergyWithTolerance(
124                 response.get(ENERGY_TOTAL), getBasalCaloriesPerDay(weightKg, heightCm));
125     }
126 
127     @Test
totalCaloriesBurned_derivedFromLbm()128     public void totalCaloriesBurned_derivedFromLbm() throws Exception {
129         Instant now = Instant.now();
130         double lbmKg = 50;
131         insertRecord(getBaseLeanBodyMassRecord(EPOCH, lbmKg * 1000));
132 
133         AggregateRecordsRequest<Energy> request =
134                 new AggregateRecordsRequest.Builder<Energy>(getTimeFilter(now.minus(1, DAYS), now))
135                         .addAggregationType(ENERGY_TOTAL)
136                         .build();
137         AggregateRecordsResponse<Energy> response = getAggregateResponse(request);
138 
139         assertEnergyWithTolerance(response.get(ENERGY_TOTAL), getBasalCaloriesPerDay(lbmKg));
140     }
141 
142     @Test
totalCaloriesBurned_derivedFromBmr()143     public void totalCaloriesBurned_derivedFromBmr() throws Exception {
144         Instant now = Instant.now();
145         double bmrWatt = 35;
146         insertRecord(getBasalMetabolicRateRecord(bmrWatt, EPOCH));
147 
148         AggregateRecordsRequest<Energy> request =
149                 new AggregateRecordsRequest.Builder<Energy>(getTimeFilter(now.minus(1, DAYS), now))
150                         .addAggregationType(ENERGY_TOTAL)
151                         .build();
152         AggregateRecordsResponse<Energy> response = getAggregateResponse(request);
153 
154         assertEnergyWithTolerance(response.get(ENERGY_TOTAL), wattToCalPerDay(bmrWatt));
155     }
156 
157     @Test
totalCaloriesBurned_hasActiveCaloriesData_sumActiveAndBasalCalories()158     public void totalCaloriesBurned_hasActiveCaloriesData_sumActiveAndBasalCalories()
159             throws Exception {
160         Instant now = Instant.now();
161         double activeCalories = 201230.3;
162         insertRecord(
163                 getActiveCaloriesBurnedRecord(
164                         activeCalories, now.minus(3, HOURS), now.minus(2, HOURS)));
165 
166         AggregateRecordsRequest<Energy> request =
167                 new AggregateRecordsRequest.Builder<Energy>(getTimeFilter(now.minus(1, DAYS), now))
168                         .addAggregationType(ENERGY_TOTAL)
169                         .build();
170         AggregateRecordsResponse<Energy> response = getAggregateResponse(request);
171 
172         assertEnergyWithTolerance(
173                 response.get(ENERGY_TOTAL), DEFAULT_BASAL_CALORIES_PER_DAY + activeCalories);
174     }
175 
176     @Test
totalCaloriesBurned_hasTotalCaloriesData_addBasalCaloriesAtGaps()177     public void totalCaloriesBurned_hasTotalCaloriesData_addBasalCaloriesAtGaps() throws Exception {
178         Instant now = Instant.now();
179         double totalCalories = 204560.3;
180         insertRecord(
181                 getTotalCaloriesBurnedRecord(
182                         totalCalories, now.minus(3, HOURS), now.minus(2, HOURS)));
183 
184         AggregateRecordsRequest<Energy> request =
185                 new AggregateRecordsRequest.Builder<Energy>(getTimeFilter(now.minus(1, DAYS), now))
186                         .addAggregationType(ENERGY_TOTAL)
187                         .build();
188         AggregateRecordsResponse<Energy> response = getAggregateResponse(request);
189 
190         assertEnergyWithTolerance(
191                 response.get(ENERGY_TOTAL),
192                 DEFAULT_BASAL_CALORIES_PER_DAY * 23 / 24 + totalCalories);
193     }
194 
195     @Test
totalCaloriesBurned_hasActiveAndTotalCaloriesData_addBasalCaloriesAtGaps()196     public void totalCaloriesBurned_hasActiveAndTotalCaloriesData_addBasalCaloriesAtGaps()
197             throws Exception {
198         Instant now = Instant.now();
199         double totalCalories = 2009870.3;
200         double activeCalories = 15120.6;
201         double overlappingActiveCalories = 30000;
202         insertRecords(
203                 getTotalCaloriesBurnedRecord(
204                         totalCalories, now.minus(24, HOURS), now.minus(2, HOURS)),
205                 getActiveCaloriesBurnedRecord(
206                         overlappingActiveCalories, now.minus(150, MINUTES), now.minus(1, HOURS)),
207                 getActiveCaloriesBurnedRecord(activeCalories, now.minus(1, HOURS), now));
208 
209         AggregateRecordsRequest<Energy> request =
210                 new AggregateRecordsRequest.Builder<Energy>(getTimeFilter(now.minus(1, DAYS), now))
211                         .addAggregationType(ENERGY_TOTAL)
212                         .build();
213         AggregateRecordsResponse<Energy> response = getAggregateResponse(request);
214 
215         // overlappingActiveCalories overlaps with totalCalories by 30 minutes out of 90 minutes
216         // for the overlapping part, we use total calories directly, not derive from active + basal
217         double partialActiveCalories = overlappingActiveCalories * 2 / 3;
218         double expected =
219                 DEFAULT_BASAL_CALORIES_PER_DAY * 2 / 24
220                         + totalCalories
221                         + activeCalories
222                         + partialActiveCalories;
223         assertEnergyWithTolerance(response.get(ENERGY_TOTAL), expected);
224     }
225 
226     @Test
totalCaloriesBurned_totalCaloriesDataWithoutGap_equalsToTotalCalories()227     public void totalCaloriesBurned_totalCaloriesDataWithoutGap_equalsToTotalCalories()
228             throws Exception {
229         Instant now = Instant.now();
230         double totalCalories = 2009870.3;
231         insertRecords(getTotalCaloriesBurnedRecord(totalCalories, now.minus(1, DAYS), now));
232 
233         AggregateRecordsRequest<Energy> request =
234                 new AggregateRecordsRequest.Builder<Energy>(getTimeFilter(now.minus(1, DAYS), now))
235                         .addAggregationType(ENERGY_TOTAL)
236                         .build();
237         AggregateRecordsResponse<Energy> response = getAggregateResponse(request);
238 
239         assertEnergyWithTolerance(response.get(ENERGY_TOTAL), totalCalories);
240     }
241 
242     @Test
totalCaloriesBurned_deriveBasalAndActiveAndTotalCalories()243     public void totalCaloriesBurned_deriveBasalAndActiveAndTotalCalories() throws Exception {
244         Instant now = Instant.now();
245         insertRecords(
246                 Arrays.asList(
247                         getTotalCaloriesBurnedRecord(10, now.minus(1, DAYS), now),
248                         getTotalCaloriesBurnedRecord(10, now.minus(2, DAYS), now.minus(1, DAYS)),
249                         getActiveCaloriesBurnedRecord(20, now.minus(4, DAYS), now.minus(3, DAYS))));
250 
251         AggregateRecordsRequest<Energy> request =
252                 new AggregateRecordsRequest.Builder<Energy>(getTimeFilter(now.minus(5, DAYS), now))
253                         .addAggregationType(ENERGY_TOTAL)
254                         .build();
255         AggregateRecordsResponse<Energy> response = getAggregateResponse(request);
256 
257         // -5    -4    -3    -2    -1    now (days)
258         // |_____|_____|_____|_____|_____|
259         // basal basal+ basal total total
260         //       active       (10)  (10)
261         //        (20)
262         assertEnergyWithTolerance(
263                 response.get(ENERGY_TOTAL), DEFAULT_BASAL_CALORIES_PER_DAY * 3 + 40);
264     }
265 
266     @Test(expected = UnsupportedOperationException.class)
testAggregation_totalCaloriesBurned_activeCalories_groupBy()267     public void testAggregation_totalCaloriesBurned_activeCalories_groupBy() throws Exception {
268         Instant now = Instant.now();
269         getAggregateResponseGroupByPeriod(
270                 new AggregateRecordsRequest.Builder<Energy>(
271                                 new TimeInstantRangeFilter.Builder()
272                                         .setStartTime(now.minus(5, DAYS))
273                                         .setEndTime(now)
274                                         .build())
275                         .addAggregationType(ENERGY_TOTAL)
276                         .addDataOriginsFilter(getDataOrigin(mPackageName))
277                         .build(),
278                 Period.ofDays(1));
279     }
280 
281     @Test
testAggregation_totalCaloriesBurned_activeCalories_groupBy_duration()282     public void testAggregation_totalCaloriesBurned_activeCalories_groupBy_duration()
283             throws Exception {
284         Instant now = Instant.now();
285         insertRecords(
286                 List.of(
287                         getBaseTotalCaloriesBurnedRecord(now.minus(1, DAYS), 10),
288                         getBaseTotalCaloriesBurnedRecord(now.minus(2, DAYS), 20),
289                         getBaseActiveCaloriesBurnedRecord(now.minus(4, DAYS), 20),
290                         getBasalMetabolicRateRecord(30, now.minus(3, DAYS))));
291 
292         List<AggregateRecordsGroupedByDurationResponse<Energy>> responses =
293                 getAggregateResponseGroupByDuration(
294                         new AggregateRecordsRequest.Builder<Energy>(
295                                         new TimeInstantRangeFilter.Builder()
296                                                 .setStartTime(now.minus(5, DAYS))
297                                                 .setEndTime(now)
298                                                 .build())
299                                 .addAggregationType(ENERGY_TOTAL)
300                                 .addDataOriginsFilter(getDataOrigin(mPackageName))
301                                 .build(),
302                         Duration.ofDays(1));
303 
304         assertThat(responses).hasSize(5);
305         assertEnergyWithTolerance(responses.get(0).get(ENERGY_TOTAL), 1564500);
306         assertEnergyWithTolerance(responses.get(1).get(ENERGY_TOTAL), 1564520);
307         assertEnergyWithTolerance(responses.get(2).get(ENERGY_TOTAL), 619200);
308         assertEnergyWithTolerance(responses.get(3).get(ENERGY_TOTAL), 20);
309         assertEnergyWithTolerance(responses.get(4).get(ENERGY_TOTAL), 10);
310     }
311 
312     @Test
testAggregation_groupByDurationLocalFilter_shiftRecordsAndFilterWithOffset()313     public void testAggregation_groupByDurationLocalFilter_shiftRecordsAndFilterWithOffset()
314             throws Exception {
315         Instant now = Instant.now();
316         ZoneOffset offset = ZoneOffset.ofHours(-1);
317         LocalDateTime localNow = LocalDateTime.ofInstant(now, offset);
318 
319         insertRecords(
320                 Arrays.asList(
321                         getBaseTotalCaloriesBurnedRecord(now.minus(1, DAYS), 10, offset),
322                         getBaseTotalCaloriesBurnedRecord(now.minus(2, DAYS), 20, offset),
323                         getBaseActiveCaloriesBurnedRecord(now.minus(4, DAYS), 20, offset),
324                         getBasalMetabolicRateRecord(30, now.minus(3, DAYS), offset)));
325 
326         List<AggregateRecordsGroupedByDurationResponse<Energy>> responses =
327                 getAggregateResponseGroupByDuration(
328                         new AggregateRecordsRequest.Builder<Energy>(
329                                         new LocalTimeRangeFilter.Builder()
330                                                 .setStartTime(localNow.minusDays(5))
331                                                 .setEndTime(localNow)
332                                                 .build())
333                                 .addAggregationType(ENERGY_TOTAL)
334                                 .addDataOriginsFilter(getDataOrigin(mPackageName))
335                                 .build(),
336                         Duration.ofDays(1));
337 
338         assertThat(responses).hasSize(5);
339         assertEnergyWithTolerance(responses.get(0).get(ENERGY_TOTAL), 1564500);
340         assertEnergyWithTolerance(responses.get(1).get(ENERGY_TOTAL), 1564520);
341         assertEnergyWithTolerance(responses.get(2).get(ENERGY_TOTAL), 619200);
342         assertEnergyWithTolerance(responses.get(3).get(ENERGY_TOTAL), 20);
343         assertEnergyWithTolerance(responses.get(4).get(ENERGY_TOTAL), 10);
344     }
345 
getTotalCaloriesBurnedRecord( double calories, Instant start, Instant end)346     private static TotalCaloriesBurnedRecord getTotalCaloriesBurnedRecord(
347             double calories, Instant start, Instant end) {
348         return new TotalCaloriesBurnedRecord.Builder(
349                         getEmptyMetadata(), start, end, Energy.fromCalories(calories))
350                 .build();
351     }
352 
getBaseTotalCaloriesBurnedRecord( Instant startTime, double value)353     private static TotalCaloriesBurnedRecord getBaseTotalCaloriesBurnedRecord(
354             Instant startTime, double value) {
355         return getBaseTotalCaloriesBurnedRecord(startTime, value, null);
356     }
357 
getBaseTotalCaloriesBurnedRecord( Instant startTime, double value, ZoneOffset offset)358     private static TotalCaloriesBurnedRecord getBaseTotalCaloriesBurnedRecord(
359             Instant startTime, double value, ZoneOffset offset) {
360         TotalCaloriesBurnedRecord.Builder builder =
361                 new TotalCaloriesBurnedRecord.Builder(
362                         getEmptyMetadata(),
363                         startTime,
364                         startTime.plus(1, DAYS),
365                         Energy.fromCalories(value));
366 
367         if (offset != null) {
368             builder.setStartZoneOffset(offset).setEndZoneOffset(offset);
369         }
370         return builder.build();
371     }
372 
getBaseActiveCaloriesBurnedRecord( Instant startTime, double energy)373     private static ActiveCaloriesBurnedRecord getBaseActiveCaloriesBurnedRecord(
374             Instant startTime, double energy) {
375         return new ActiveCaloriesBurnedRecord.Builder(
376                         getEmptyMetadata(),
377                         startTime,
378                         startTime.plus(1, DAYS),
379                         Energy.fromCalories(energy))
380                 .build();
381     }
382 
getBaseActiveCaloriesBurnedRecord( Instant startTime, double energy, ZoneOffset offset)383     private static ActiveCaloriesBurnedRecord getBaseActiveCaloriesBurnedRecord(
384             Instant startTime, double energy, ZoneOffset offset) {
385         return new ActiveCaloriesBurnedRecord.Builder(
386                         getEmptyMetadata(),
387                         startTime,
388                         startTime.plus(1, DAYS),
389                         Energy.fromCalories(energy))
390                 .setStartZoneOffset(offset)
391                 .setEndZoneOffset(offset)
392                 .build();
393     }
394 
getBasalCaloriesPerDay(double weightKg, double heightCm)395     private static double getBasalCaloriesPerDay(double weightKg, double heightCm) {
396         // We use Mifflin-St Jeor Equation to calculate BMR
397         // BMR (kcal/day) = 10 * weight in kg + 6.25 * height in cm
398         //                  -5 * age in years + gender constant
399         // gender constant: Men(5), Women(-161), Unspecified(-78)
400         double defaultAge = 30;
401         return (10 * weightKg + 6.25 * heightCm - 5 * defaultAge - 78) * 1000;
402     }
403 
getBasalCaloriesPerDay(double lbmKg)404     private static double getBasalCaloriesPerDay(double lbmKg) {
405         return (370 + 21.6 * lbmKg) * 1000;
406     }
407 
wattToCalPerDay(double watt)408     private static double wattToCalPerDay(double watt) {
409         return watt * 860 * 24;
410     }
411 }
412