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