diff --git a/app/src/main/kotlin/app/aaps/activities/HistoryBrowseActivity.kt b/app/src/main/kotlin/app/aaps/activities/HistoryBrowseActivity.kt index 710faec2d23..7aababe2f49 100644 --- a/app/src/main/kotlin/app/aaps/activities/HistoryBrowseActivity.kt +++ b/app/src/main/kotlin/app/aaps/activities/HistoryBrowseActivity.kt @@ -22,6 +22,7 @@ import app.aaps.core.interfaces.rx.events.EventIobCalculationProgress import app.aaps.core.interfaces.rx.events.EventRefreshOverview import app.aaps.core.interfaces.rx.events.EventScale import app.aaps.core.interfaces.rx.events.EventUpdateOverviewGraph +import app.aaps.core.interfaces.stats.TirCalculator import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.interfaces.utils.fabric.FabricPrivacy import app.aaps.core.interfaces.workflow.CalculationWorkflow @@ -31,6 +32,7 @@ import app.aaps.core.ui.activities.TranslatedDaggerAppCompatActivity import app.aaps.core.ui.extensions.toVisibility import app.aaps.core.ui.extensions.toVisibilityKeepSpace import app.aaps.databinding.ActivityHistorybrowseBinding +import app.aaps.plugins.main.general.overview.TirHelper import app.aaps.plugins.main.general.overview.graphData.GraphData import com.google.android.material.datepicker.MaterialDatePicker import io.reactivex.rxjava3.disposables.CompositeDisposable @@ -56,6 +58,8 @@ class HistoryBrowseActivity : TranslatedDaggerAppCompatActivity() { @Inject lateinit var rh: ResourceHelper @Inject lateinit var aapsLogger: AAPSLogger @Inject lateinit var graphDataProvider: Provider + @Inject lateinit var tirCalculator: TirCalculator + @Inject lateinit var tirHelper: TirHelper private val disposable = CompositeDisposable() @@ -400,6 +404,38 @@ class HistoryBrowseActivity : TranslatedDaggerAppCompatActivity() { ).toVisibility() secondaryGraphsData[g].performUpdate() } + + updateTirChart(menuChartSettings) + } + + private fun updateTirChart(menuChartSettings: List>) { + val tirEnabled = menuChartSettings.isNotEmpty() && + menuChartSettings[0][OverviewMenus.CharType.TIR.ordinal] + + aapsLogger.debug(LTag.UI, "TIR Chart (History) - enabled: $tirEnabled, fromTime: ${historyBrowserData.overviewData.fromTime}, toTime: ${historyBrowserData.overviewData.toTime}") + + if (tirEnabled) { + try { + val tirData = tirHelper.calculateTirForRange( + historyBrowserData.overviewData.fromTime, + historyBrowserData.overviewData.toTime + ) + + // Only show chart if there's data + if (tirData.count > 0) { + val titleText = rh.gs(app.aaps.plugins.main.R.string.tir_shown_time) + binding.tirChart.tirChartLayout.visibility = android.view.View.VISIBLE + binding.tirChart.tirChartView.setData(tirData, titleText) + } else { + binding.tirChart.tirChartLayout.visibility = android.view.View.GONE + } + } catch (e: Exception) { + aapsLogger.error(LTag.UI, "Error calculating TIR for history", e) + binding.tirChart.tirChartLayout.visibility = android.view.View.GONE + } + } else { + binding.tirChart.tirChartLayout.visibility = android.view.View.GONE + } } private fun updateCalcProgress(percent: Int) { diff --git a/app/src/main/res/layout/activity_historybrowse.xml b/app/src/main/res/layout/activity_historybrowse.xml index 908c5341d87..0ad4a3386ab 100644 --- a/app/src/main/res/layout/activity_historybrowse.xml +++ b/app/src/main/res/layout/activity_historybrowse.xml @@ -109,6 +109,10 @@ android:layout_height="wrap_content" android:orientation="vertical" /> + + diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/OverviewMenus.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/OverviewMenus.kt index 9fe3862b54b..5fa5b55d944 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/OverviewMenus.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/OverviewMenus.kt @@ -16,6 +16,7 @@ interface OverviewMenus { SEN, VAR_SEN, ACT, + TIR, DEVSLOPE, HR, STEPS diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/TirCalculator.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/TirCalculator.kt index b3163f300fc..787f0f01f2b 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/TirCalculator.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/TirCalculator.kt @@ -7,5 +7,7 @@ import androidx.collection.LongSparseArray interface TirCalculator { fun calculate(days: Long, lowMgdl: Double, highMgdl: Double): LongSparseArray + fun calculateToday(lowMgdl: Double, highMgdl: Double): TIR + fun calculateRange(start: Long, end: Long, lowMgdl: Double, highMgdl: Double): TIR fun stats(context: Context): TableLayout } \ No newline at end of file diff --git a/implementation/src/main/kotlin/app/aaps/implementation/stats/TirCalculatorImpl.kt b/implementation/src/main/kotlin/app/aaps/implementation/stats/TirCalculatorImpl.kt index a4aaa6320e8..5b27d963702 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/stats/TirCalculatorImpl.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/stats/TirCalculatorImpl.kt @@ -50,6 +50,29 @@ class TirCalculatorImpl @Inject constructor( return result } + override fun calculateToday(lowMgdl: Double, highMgdl: Double): TIR { + val midnight = MidnightTime.calc(dateUtil.now()) + val now = dateUtil.now() + return calculateRange(midnight, now, lowMgdl, highMgdl) + } + + override fun calculateRange(start: Long, end: Long, lowMgdl: Double, highMgdl: Double): TIR { + if (lowMgdl < 39) throw RuntimeException("Low below 39") + if (lowMgdl > highMgdl) throw RuntimeException("Low > High") + + val bgReadings = persistenceLayer.getBgReadingsDataFromTimeToTime(start, end, true) + val tir = TirImpl(start, lowMgdl, highMgdl) + + for (bg in bgReadings) { + if (bg.value < 39) tir.error() + if (bg.value >= 39 && bg.value < lowMgdl) tir.below() + if (bg.value in lowMgdl..highMgdl) tir.inRange() + if (bg.value > highMgdl) tir.above() + } + + return tir + } + private fun averageTIR(tirs: LongSparseArray): TIR { val totalTir = if (tirs.size() > 0) { TirImpl(tirs.valueAt(0).date, tirs.valueAt(0).lowThreshold, tirs.valueAt(0).highThreshold) diff --git a/implementation/src/test/kotlin/app/aaps/implementation/stats/TirCalculatorImplTest.kt b/implementation/src/test/kotlin/app/aaps/implementation/stats/TirCalculatorImplTest.kt new file mode 100644 index 00000000000..bcc60ab4b19 --- /dev/null +++ b/implementation/src/test/kotlin/app/aaps/implementation/stats/TirCalculatorImplTest.kt @@ -0,0 +1,771 @@ +package app.aaps.implementation.stats + +import androidx.collection.LongSparseArray +import app.aaps.core.data.model.GV +import app.aaps.core.data.model.SourceSensor +import app.aaps.core.data.model.TrendArrow +import app.aaps.core.data.time.T +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.profile.ProfileUtil +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.interfaces.utils.MidnightTime +import app.aaps.shared.tests.TestBase +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever + +class TirCalculatorImplTest : TestBase() { + + @Mock lateinit var rh: ResourceHelper + @Mock lateinit var profileUtil: ProfileUtil + @Mock lateinit var dateUtil: DateUtil + @Mock lateinit var persistenceLayer: PersistenceLayer + + private lateinit var tirCalculator: TirCalculatorImpl + + private val now = 1000000000L + private val midnight = MidnightTime.calc(now) + private val lowMgdl = 70.0 + private val highMgdl = 180.0 + + companion object { + private const val MMOL_TO_MGDL = 18.0182 // Conversion factor from mmol/L to mg/dL + private const val ERROR_THRESHOLD_MGDL = 39.0 // Error threshold in mg/dL + } + + @BeforeEach + fun setup() { + tirCalculator = TirCalculatorImpl(rh, profileUtil, dateUtil, persistenceLayer) + whenever(dateUtil.now()).thenReturn(now) + } + + // ===== calculate() method tests ===== + + @Test + fun `calculate throws exception when lowMgdl is below 39`() { + assertThrows { + tirCalculator.calculate(7, 38.0, highMgdl) + } + } + + @Test + fun `calculate throws exception when lowMgdl is greater than highMgdl`() { + assertThrows { + tirCalculator.calculate(7, 200.0, 180.0) + } + } + + @Test + fun `calculate returns empty array when no bg readings`() { + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(emptyList()) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + assertThat(result.size()).isEqualTo(0) + } + + @Test + fun `calculate groups readings by day correctly`() { + val day1 = midnight + val day2 = midnight - T.days(1).msecs() + val day3 = midnight - T.days(2).msecs() + + val readings = listOf( + createGV(day1, 100.0), + createGV(day1 + T.hours(1).msecs(), 120.0), + createGV(day2, 90.0), + createGV(day2 + T.hours(2).msecs(), 150.0), + createGV(day3, 80.0) + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + assertThat(result.size()).isEqualTo(3) + assertThat(result[MidnightTime.calc(day1)]).isNotNull() + assertThat(result[MidnightTime.calc(day2)]).isNotNull() + assertThat(result[MidnightTime.calc(day3)]).isNotNull() + } + + @Test + fun `calculate categorizes glucose values correctly into ranges`() { + val readings = listOf( + createGV(midnight, 30.0), // error + createGV(midnight + T.mins(5).msecs(), 50.0), // below + createGV(midnight + T.mins(10).msecs(), 100.0), // in range + createGV(midnight + T.mins(15).msecs(), 200.0) // above + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + assertThat(result.size()).isEqualTo(1) + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir).isNotNull() + assertThat(tir?.error).isEqualTo(1) + assertThat(tir?.below).isEqualTo(1) + assertThat(tir?.inRange).isEqualTo(1) + assertThat(tir?.above).isEqualTo(1) + assertThat(tir?.count).isEqualTo(3) // error doesn't count + } + + @Test + fun `calculate handles edge cases at thresholds`() { + val readings = listOf( + createGV(midnight, ERROR_THRESHOLD_MGDL), // exactly at error boundary - should be below + createGV(midnight + T.mins(5).msecs(), 70.0), // exactly at low threshold - in range + createGV(midnight + T.mins(10).msecs(), 180.0), // exactly at high threshold - in range + createGV(midnight + T.mins(15).msecs(), 180.1) // just above high - above + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.below).isEqualTo(1) // ERROR_THRESHOLD_MGDL + assertThat(tir?.inRange).isEqualTo(2) // 70.0 and 180.0 + assertThat(tir?.above).isEqualTo(1) // 180.1 + assertThat(tir?.error).isEqualTo(0) + } + + @Test + fun `calculate sets correct thresholds in TIR objects`() { + val readings = listOf(createGV(midnight, 100.0)) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.lowThreshold).isEqualTo(lowMgdl) + assertThat(tir?.highThreshold).isEqualTo(highMgdl) + } + + @Test + fun `calculate handles multiple readings on same day`() { + val readings = mutableListOf() + // Create 288 readings for one day (every 5 minutes) + for (i in 0 until 288) { + readings.add(createGV(midnight + T.mins(i * 5L).msecs(), 100.0)) + } + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.count).isEqualTo(288) + assertThat(tir?.inRange).isEqualTo(288) + } + + // ===== calculateToday() method tests ===== + + @Test + fun `calculateToday throws exception when lowMgdl is below 39`() { + assertThrows { + tirCalculator.calculateToday(38.0, highMgdl) + } + } + + @Test + fun `calculateToday throws exception when lowMgdl is greater than highMgdl`() { + assertThrows { + tirCalculator.calculateToday(200.0, 180.0) + } + } + + @Test + fun `calculateToday returns TIR for current day only`() { + val currentMidnight = MidnightTime.calc(now) + + val readings = listOf( + createGV(currentMidnight + T.hours(6).msecs(), 100.0), + createGV(currentMidnight + T.hours(12).msecs(), 120.0), + createGV(currentMidnight - T.days(1).msecs(), 90.0) // previous day - should not be included + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenAnswer { invocation -> + val start = invocation.arguments[0] as Long + val end = invocation.arguments[1] as Long + readings.filter { it.timestamp in start..end } + } + + val result = tirCalculator.calculateToday(lowMgdl, highMgdl) + + assertThat(result.count).isEqualTo(2) + assertThat(result.inRange).isEqualTo(2) + } + + @Test + fun `calculateToday with no readings returns empty TIR`() { + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(emptyList()) + + val result = tirCalculator.calculateToday(lowMgdl, highMgdl) + + assertThat(result.count).isEqualTo(0) + assertThat(result.below).isEqualTo(0) + assertThat(result.inRange).isEqualTo(0) + assertThat(result.above).isEqualTo(0) + assertThat(result.error).isEqualTo(0) + } + + // ===== calculateRange() method tests ===== + + @Test + fun `calculateRange throws exception when lowMgdl is below 39`() { + assertThrows { + tirCalculator.calculateRange(midnight, now, 38.0, highMgdl) + } + } + + @Test + fun `calculateRange throws exception when lowMgdl is greater than highMgdl`() { + assertThrows { + tirCalculator.calculateRange(midnight, now, 200.0, 180.0) + } + } + + @Test + fun `calculateRange returns TIR for specified time range`() { + val start = midnight + val end = midnight + T.hours(6).msecs() + + val readings = listOf( + createGV(start + T.hours(1).msecs(), 100.0), + createGV(start + T.hours(2).msecs(), 120.0), + createGV(start + T.hours(3).msecs(), 150.0), + createGV(end + T.hours(1).msecs(), 90.0) // outside range - should not be included + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(start, end, true)) + .thenReturn(readings.filter { it.timestamp in start..end }) + + val result = tirCalculator.calculateRange(start, end, lowMgdl, highMgdl) + + assertThat(result.count).isEqualTo(3) + assertThat(result.inRange).isEqualTo(3) + assertThat(result.date).isEqualTo(start) + } + + @Test + fun `calculateRange categorizes all glucose ranges correctly`() { + val start = midnight + val end = midnight + T.hours(2).msecs() + + val readings = listOf( + createGV(start, 20.0), // error + createGV(start + T.mins(10).msecs(), 38.0), // error + createGV(start + T.mins(20).msecs(), ERROR_THRESHOLD_MGDL), // below + createGV(start + T.mins(30).msecs(), 60.0), // below + createGV(start + T.mins(40).msecs(), 70.0), // in range + createGV(start + T.mins(50).msecs(), 100.0), // in range + createGV(start + T.mins(60).msecs(), 180.0), // in range + createGV(start + T.mins(70).msecs(), 181.0), // above + createGV(start + T.mins(80).msecs(), 250.0) // above + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(start, end, true)) + .thenReturn(readings) + + val result = tirCalculator.calculateRange(start, end, lowMgdl, highMgdl) + + assertThat(result.error).isEqualTo(2) // 20.0, 38.0 + assertThat(result.below).isEqualTo(2) // ERROR_THRESHOLD_MGDL, 60.0 + assertThat(result.inRange).isEqualTo(3) // 70.0, 100.0, 180.0 + assertThat(result.above).isEqualTo(2) // 181.0, 250.0 + assertThat(result.count).isEqualTo(7) // excludes errors + } + + @Test + fun `calculateRange with custom thresholds`() { + val customLow = 80.0 + val customHigh = 140.0 + val start = midnight + val end = midnight + T.hours(1).msecs() + + val readings = listOf( + createGV(start, 70.0), // below (< 80) + createGV(start + T.mins(10).msecs(), 90.0), // in range + createGV(start + T.mins(20).msecs(), 130.0), // in range + createGV(start + T.mins(30).msecs(), 150.0) // above (> 140) + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(start, end, true)) + .thenReturn(readings) + + val result = tirCalculator.calculateRange(start, end, customLow, customHigh) + + assertThat(result.below).isEqualTo(1) + assertThat(result.inRange).isEqualTo(2) + assertThat(result.above).isEqualTo(1) + assertThat(result.lowThreshold).isEqualTo(customLow) + assertThat(result.highThreshold).isEqualTo(customHigh) + } + + @Test + fun `calculateRange returns empty TIR when no readings in range`() { + val start = midnight + val end = midnight + T.hours(1).msecs() + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(start, end, true)) + .thenReturn(emptyList()) + + val result = tirCalculator.calculateRange(start, end, lowMgdl, highMgdl) + + assertThat(result.count).isEqualTo(0) + assertThat(result.below).isEqualTo(0) + assertThat(result.inRange).isEqualTo(0) + assertThat(result.above).isEqualTo(0) + } + + // ===== Integration and realistic scenario tests ===== + + @Test + fun `realistic 24-hour scenario with typical glucose variations`() { + val readings = mutableListOf() + + // Simulate realistic glucose pattern over 24 hours + // Night: stable in range + for (i in 0..8) { + readings.add(createGV(midnight + T.hours(i.toLong()).msecs(), 95.0 + (i * 2))) + } + + // Morning spike + readings.add(createGV(midnight + T.hours(9).msecs(), 190.0)) // above + readings.add(createGV(midnight + T.hours(10).msecs(), 185.0)) // above + + // Back in range + for (i in 11..14) { + readings.add(createGV(midnight + T.hours(i.toLong()).msecs(), 120.0)) + } + + // Low episode + readings.add(createGV(midnight + T.hours(15).msecs(), 65.0)) // below + readings.add(createGV(midnight + T.hours(16).msecs(), 60.0)) // below + + // Recovery to range + for (i in 17..23) { + readings.add(createGV(midnight + T.hours(i.toLong()).msecs(), 110.0)) + } + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(1, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir).isNotNull() + assertThat(tir?.count).isEqualTo(24) + assertThat(tir?.below).isEqualTo(2) // 2 low readings + assertThat(tir?.inRange).isEqualTo(20) // 20 in-range readings + assertThat(tir?.above).isEqualTo(2) // 2 high readings + assertThat(tir?.error).isEqualTo(0) + } + + @Test + fun `multiple days with different glucose patterns`() { + val day1 = midnight + val day2 = midnight - T.days(1).msecs() + + val readings = listOf( + // Day 1: all in range + createGV(day1, 100.0), + createGV(day1 + T.hours(6).msecs(), 110.0), + createGV(day1 + T.hours(12).msecs(), 120.0), + createGV(day1 + T.hours(18).msecs(), 130.0), + + // Day 2: mixed + createGV(day2, 50.0), // below + createGV(day2 + T.hours(6).msecs(), 100.0), // in range + createGV(day2 + T.hours(12).msecs(), 200.0), // above + createGV(day2 + T.hours(18).msecs(), 30.0) // error + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(2, lowMgdl, highMgdl) + + val tir1 = result[MidnightTime.calc(day1)] + assertThat(tir1?.count).isEqualTo(4) + assertThat(tir1?.inRange).isEqualTo(4) + assertThat(tir1?.below).isEqualTo(0) + assertThat(tir1?.above).isEqualTo(0) + + val tir2 = result[MidnightTime.calc(day2)] + assertThat(tir2?.count).isEqualTo(3) // error doesn't count + assertThat(tir2?.below).isEqualTo(1) + assertThat(tir2?.inRange).isEqualTo(1) + assertThat(tir2?.above).isEqualTo(1) + assertThat(tir2?.error).isEqualTo(1) + } + + @Test + fun `stress test with large number of readings`() { + val readings = mutableListOf() + + // Generate 2016 readings (7 days * 288 readings per day) + for (day in 0..6) { + val dayStart = midnight - T.days(day.toLong()).msecs() + for (reading in 0..287) { + val value = when { + reading < 50 -> 65.0 // below + reading < 250 -> 100.0 // in range + else -> 190.0 // above + } + readings.add(createGV(dayStart + T.mins(reading * 5L).msecs(), value)) + } + } + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + assertThat(result.size()).isEqualTo(7) + + // Verify each day + for (day in 0..6) { + val dayMidnight = MidnightTime.calc(midnight - T.days(day.toLong()).msecs()) + val tir = result[dayMidnight] + assertThat(tir?.count).isEqualTo(288) + assertThat(tir?.below).isEqualTo(50) + assertThat(tir?.inRange).isEqualTo(200) + assertThat(tir?.above).isEqualTo(38) + } + } + + @Test + fun `verify time range boundaries are inclusive`() { + val start = midnight + val end = midnight + T.hours(1).msecs() + + val readings = listOf( + createGV(start - 1, 100.0), // just before start + createGV(start, 110.0), // exactly at start + createGV(start + T.mins(30).msecs(), 120.0), // middle + createGV(end, 130.0), // exactly at end + createGV(end + 1, 140.0) // just after end + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(start, end, true)) + .thenReturn(readings.filter { it.timestamp >= start && it.timestamp <= end }) + + val result = tirCalculator.calculateRange(start, end, lowMgdl, highMgdl) + + // Should include start and end timestamps + assertThat(result.count).isEqualTo(3) // start, middle, end + } + + @Test + fun `calculate handles NaN and Infinity values`() { + val readings = listOf( + createGV(midnight, Double.NaN), // NaN should be ignored + createGV(midnight + T.mins(5).msecs(), Double.POSITIVE_INFINITY), // Infinity treated as above + createGV(midnight + T.mins(10).msecs(), 100.0) // in range + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.error).isEqualTo(0) + assertThat(tir?.below).isEqualTo(0) + assertThat(tir?.inRange).isEqualTo(1) + assertThat(tir?.above).isEqualTo(1) // Infinity + assertThat(tir?.count).isEqualTo(2) + } + + @Test + fun `calculate handles negative timestamps and raw null`() { + val negativeTs = -1_000_000L + val readings = listOf( + // raw = null should not affect classification + GV(timestamp = negativeTs, value = 100.0, noise = null, raw = null, trendArrow = TrendArrow.FLAT, sourceSensor = SourceSensor.UNKNOWN), + createGV(midnight, 90.0) + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + // Both readings should be present and counted + val tirNeg = result[MidnightTime.calc(negativeTs)] + val tirMid = result[MidnightTime.calc(midnight)] + assertThat(tirNeg?.count).isEqualTo(1) + assertThat(tirNeg?.inRange).isEqualTo(1) + assertThat(tirMid?.count).isEqualTo(1) + } + + @Test + fun `calculate counts duplicate timestamps separately`() { + val ts = midnight + T.hours(2).msecs() + val readings = listOf( + createGV(ts, 100.0), + createGV(ts, 200.0) + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.count).isEqualTo(2) + assertThat(tir?.inRange).isEqualTo(1) + assertThat(tir?.above).isEqualTo(1) + } + + @Test + fun `calculate handles data holes without filling readings`() { + val readings = listOf( + createGV(midnight, 100.0), + createGV(midnight + T.hours(12).msecs(), 110.0) // large gap, should be counted but not filled + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.count).isEqualTo(2) + assertThat(tir?.inRange).isEqualTo(2) + } + + // ===== mmol/L unit tests (using conversion to mg/dL) ===== + + @Test + fun `calculate with mmol L values converted to mg dL`() { + // mmol/L to mg/dL conversion: multiply by ~MMOL_TO_MGDL + // Low: 3.9 mmol/L = ~70 mg/dL, High: 10.0 mmol/L = ~180 mg/dL + val lowMmol = 3.9 + val highMmol = 10.0 + val lowMgdl = lowMmol * MMOL_TO_MGDL // ~70.27 + val highMgdl = highMmol * MMOL_TO_MGDL // ~180.18 + + val readings = listOf( + createGV(midnight, 2.8 * MMOL_TO_MGDL), // 50 mg/dL - below + createGV(midnight + T.mins(5).msecs(), 5.5 * MMOL_TO_MGDL), // 99 mg/dL - in range + createGV(midnight + T.mins(10).msecs(), 11.0 * MMOL_TO_MGDL) // 198 mg/dL - above + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.below).isEqualTo(1) // 2.8 mmol/L + assertThat(tir?.inRange).isEqualTo(1) // 5.5 mmol/L + assertThat(tir?.above).isEqualTo(1) // 11.0 mmol/L + } + + @Test + fun `mmol L error threshold is approximately 2_2 mmol L`() { + // Error threshold: < 39 mg/dL = ~2.16 mmol/L + val errorThresholdMmol = ERROR_THRESHOLD_MGDL / MMOL_TO_MGDL // ~2.164 mmol/L + + val readings = listOf( + createGV(midnight, 2.0 * MMOL_TO_MGDL), // ~36 mg/dL - error + createGV(midnight + T.mins(5).msecs(), 2.2 * MMOL_TO_MGDL), // ~39.6 mg/dL - below + createGV(midnight + T.mins(10).msecs(), 2.15 * MMOL_TO_MGDL) // ~38.7 mg/dL - error (< 39) + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.error).isEqualTo(2) // 2.0 and 2.15 mmol/L (both < 39 mg/dL) + assertThat(tir?.below).isEqualTo(1) // 2.2 mmol/L + assertThat(tir?.count).isEqualTo(1) // errors don't count + } + + @Test + fun `mmol L boundary values at thresholds`() { + // Test exact boundaries in mmol/L + // 3.9 mmol/L = 70.27 mg/dL (in range) + // 10.0 mmol/L = 180.18 mg/dL (in range) + val lowMgdl = 3.9 * MMOL_TO_MGDL // ~70.27 + val highMgdl = 10.0 * MMOL_TO_MGDL // ~180.18 + + val readings = listOf( + createGV(midnight, 3.85 * MMOL_TO_MGDL), // Just below low - below + createGV(midnight + T.mins(5).msecs(), 3.9 * MMOL_TO_MGDL), // Exactly at low - in range + createGV(midnight + T.mins(10).msecs(), 10.0 * MMOL_TO_MGDL), // Exactly at high - in range + createGV(midnight + T.mins(15).msecs(), 10.1 * MMOL_TO_MGDL) // Just above high - above + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.below).isEqualTo(1) // 3.85 mmol/L + assertThat(tir?.inRange).isEqualTo(2) // 3.9 and 10.0 mmol/L + assertThat(tir?.above).isEqualTo(1) // 10.1 mmol/L + } + + @Test + fun `realistic mmol L scenario with typical glucose variations`() { + // Realistic European/international scenario using mmol/L values + val lowMgdl = 3.9 * MMOL_TO_MGDL // 3.9 mmol/L + val highMgdl = 10.0 * MMOL_TO_MGDL // 10.0 mmol/L + + val readings = mutableListOf() + + // Night: stable around 5.5 mmol/L (99 mg/dL) + for (i in 0..6) { + readings.add(createGV(midnight + T.hours(i.toLong()).msecs(), 5.5 * MMOL_TO_MGDL)) + } + + // Morning spike to 11.0 mmol/L (198 mg/dL) + readings.add(createGV(midnight + T.hours(7).msecs(), 11.0 * MMOL_TO_MGDL)) + readings.add(createGV(midnight + T.hours(8).msecs(), 10.5 * MMOL_TO_MGDL)) + + // Back to range 6.0 mmol/L (108 mg/dL) + for (i in 9..13) { + readings.add(createGV(midnight + T.hours(i.toLong()).msecs(), 6.0 * MMOL_TO_MGDL)) + } + + // Low episode 3.5 mmol/L (63 mg/dL) + readings.add(createGV(midnight + T.hours(14).msecs(), 3.5 * MMOL_TO_MGDL)) + readings.add(createGV(midnight + T.hours(15).msecs(), 3.3 * MMOL_TO_MGDL)) + + // Recovery 5.0 mmol/L (90 mg/dL) + for (i in 16..23) { + readings.add(createGV(midnight + T.hours(i.toLong()).msecs(), 5.0 * MMOL_TO_MGDL)) + } + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(1, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.count).isEqualTo(24) + assertThat(tir?.below).isEqualTo(2) // 2 low readings + assertThat(tir?.inRange).isEqualTo(20) // 20 in-range readings + assertThat(tir?.above).isEqualTo(2) // 2 high readings + } + + @Test + fun `calculateRange with mmol L custom thresholds`() { + // Using tighter control range: 4.0-8.0 mmol/L + val customLowMgdl = 4.0 * MMOL_TO_MGDL // 72 mg/dL + val customHighMgdl = 8.0 * MMOL_TO_MGDL // 144 mg/dL + + val start = midnight + val end = midnight + T.hours(6).msecs() + + val readings = listOf( + createGV(start, 3.8 * MMOL_TO_MGDL), // 68 mg/dL - below + createGV(start + T.hours(1).msecs(), 5.0 * MMOL_TO_MGDL), // 90 mg/dL - in range + createGV(start + T.hours(2).msecs(), 7.0 * MMOL_TO_MGDL), // 126 mg/dL - in range + createGV(start + T.hours(3).msecs(), 9.0 * MMOL_TO_MGDL), // 162 mg/dL - above + createGV(start + T.hours(4).msecs(), 6.0 * MMOL_TO_MGDL), // 108 mg/dL - in range + createGV(start + T.hours(5).msecs(), 10.0 * MMOL_TO_MGDL) // 180 mg/dL - above + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(start, end, true)) + .thenReturn(readings) + + val result = tirCalculator.calculateRange(start, end, customLowMgdl, customHighMgdl) + + assertThat(result.below).isEqualTo(1) // 3.8 mmol/L + assertThat(result.inRange).isEqualTo(3) // 5.0, 7.0, 6.0 mmol/L + assertThat(result.above).isEqualTo(2) // 9.0, 10.0 mmol/L + assertThat(result.count).isEqualTo(6) + } + + @Test + fun `mmol L values in tight diabetes range 3_9 to 7_8 mmol L`() { + // Tight diabetes control range (common in Europe) + val lowMgdl = 3.9 * MMOL_TO_MGDL // 70 mg/dL + val highMgdl = 7.8 * MMOL_TO_MGDL // 140 mg/dL + + val readings = listOf( + createGV(midnight, 3.5 * MMOL_TO_MGDL), // 63 mg/dL - below + createGV(midnight + T.mins(10).msecs(), 5.0 * MMOL_TO_MGDL), // 90 mg/dL - in range + createGV(midnight + T.mins(20).msecs(), 6.5 * MMOL_TO_MGDL), // 117 mg/dL - in range + createGV(midnight + T.mins(30).msecs(), 7.5 * MMOL_TO_MGDL), // 135 mg/dL - in range + createGV(midnight + T.mins(40).msecs(), 8.0 * MMOL_TO_MGDL), // 144 mg/dL - above + createGV(midnight + T.mins(50).msecs(), 12.0 * MMOL_TO_MGDL) // 216 mg/dL - above + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.below).isEqualTo(1) // 3.5 mmol/L + assertThat(tir?.inRange).isEqualTo(3) // 5.0, 6.5, 7.5 mmol/L + assertThat(tir?.above).isEqualTo(2) // 8.0, 12.0 mmol/L + assertThat(tir?.count).isEqualTo(6) + } + + @Test + fun `verify mmol L conversion factor accuracy`() { + // Verify the conversion factor is correct + val conversionFactor = MMOL_TO_MGDL + + // Test known conversions + // 5.0 mmol/L = 90.09 mg/dL (in range with 70-180) + // 9.0 mmol/L = 162.16 mg/dL (in range with 70-180) + val value5mmol = 5.0 * conversionFactor + val value9mmol = 9.0 * conversionFactor + + assertThat(value5mmol).isWithin(0.1).of(90.09) + assertThat(value9mmol).isWithin(0.1).of(162.16) + + // Use these in actual calculation + val readings = listOf( + createGV(midnight, value5mmol), + createGV(midnight + T.mins(5).msecs(), value9mmol) + ) + + whenever(persistenceLayer.getBgReadingsDataFromTimeToTime(any(), any(), any())) + .thenReturn(readings) + + val result = tirCalculator.calculate(7, lowMgdl, highMgdl) + + val tir = result[MidnightTime.calc(midnight)] + assertThat(tir?.count).isEqualTo(2) + assertThat(tir?.inRange).isEqualTo(2) // Both should be in range with standard thresholds (70-180 mg/dL) + } + + // ===== Helper method ===== + + private fun createGV(timestamp: Long, value: Double): GV = + GV( + timestamp = timestamp, + value = value, + noise = null, + raw = value, + trendArrow = TrendArrow.FLAT, + sourceSensor = SourceSensor.UNKNOWN + ) +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/OverviewFragment.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/OverviewFragment.kt index 7dab667132a..c9ff5dc438e 100644 --- a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/OverviewFragment.kt +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/OverviewFragment.kt @@ -81,6 +81,7 @@ import app.aaps.core.interfaces.rx.events.EventWearUpdateTiles import app.aaps.core.interfaces.rx.weardata.EventData import app.aaps.core.interfaces.source.DexcomBoyda import app.aaps.core.interfaces.source.XDripSource +import app.aaps.core.interfaces.stats.TirCalculator import app.aaps.core.interfaces.ui.UiInteraction import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.interfaces.utils.DecimalFormatter @@ -161,6 +162,8 @@ class OverviewFragment : DaggerFragment(), View.OnClickListener, OnLongClickList @Inject lateinit var decimalFormatter: DecimalFormatter @Inject lateinit var graphDataProvider: Provider @Inject lateinit var commandQueue: CommandQueue + @Inject lateinit var tirCalculator: TirCalculator + @Inject lateinit var tirHelper: TirHelper private val disposable = CompositeDisposable() @@ -1171,6 +1174,36 @@ class OverviewFragment : DaggerFragment(), View.OnClickListener, OnLongClickList ).toVisibility() secondaryGraphsData[g].performUpdate() } + + // Update TIR chart + updateTirChart(menuChartSettings) + } + + private fun updateTirChart(menuChartSettings: List>) { + _binding ?: return + + // Check if TIR is enabled in primary graph settings (single checkbox) + val tirEnabled = menuChartSettings.isNotEmpty() && + menuChartSettings[0][OverviewMenus.CharType.TIR.ordinal] + + aapsLogger.debug("TIR Chart - enabled: $tirEnabled, settings size: ${menuChartSettings.size}, TIR ordinal: ${OverviewMenus.CharType.TIR.ordinal}") + + if (tirEnabled) { + try { + val chartData = tirHelper.calculateTodayChartData() + if (chartData != null) { + binding.graphsLayout.tirChart.tirChartLayout.visibility = View.VISIBLE + binding.graphsLayout.tirChart.tirChartView.setChartData(chartData) + } else { + binding.graphsLayout.tirChart.tirChartLayout.visibility = View.GONE + } + } catch (e: Exception) { + aapsLogger.error("Error calculating TIR", e) + binding.graphsLayout.tirChart.tirChartLayout.visibility = View.GONE + } + } else { + binding.graphsLayout.tirChart.tirChartLayout.visibility = View.GONE + } } private fun updateCalcProgress() { diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/OverviewMenusImpl.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/OverviewMenusImpl.kt index 7401b1ec27a..c54623e9711 100644 --- a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/OverviewMenusImpl.kt +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/OverviewMenusImpl.kt @@ -70,6 +70,7 @@ class OverviewMenusImpl @Inject constructor( SEN(R.string.overview_show_sensitivity, app.aaps.core.ui.R.attr.ratioColor, app.aaps.core.ui.R.attr.menuTextColorInverse, primary = false, secondary = true, shortnameId = R.string.sensitivity_shortname), VAR_SENS(R.string.overview_show_variable_sens, app.aaps.core.ui.R.attr.ratioColor, app.aaps.core.ui.R.attr.menuTextColorInverse, primary = false, secondary = true, shortnameId = R.string.variable_sensitivity_shortname), ACT(R.string.overview_show_activity, app.aaps.core.ui.R.attr.activityColor, app.aaps.core.ui.R.attr.menuTextColor, primary = true, secondary = false, shortnameId = R.string.activity_shortname), + TIR(R.string.overview_show_tir, app.aaps.core.ui.R.attr.bgInRange, app.aaps.core.ui.R.attr.menuTextColor, primary = true, secondary = false, shortnameId = R.string.tir_shortname), DEVSLOPE(R.string.overview_show_deviation_slope, app.aaps.core.ui.R.attr.devSlopePosColor, app.aaps.core.ui.R.attr.menuTextColor, primary = false, secondary = true, shortnameId = R.string.devslope_shortname), HR(R.string.overview_show_heartRate, app.aaps.core.ui.R.attr.heartRateColor, app.aaps.core.ui.R.attr.menuTextColor, primary = false, secondary = true, shortnameId = R.string.heartRate_shortname), STEPS(R.string.overview_show_steps, app.aaps.core.ui.R.attr.stepsColor, app.aaps.core.ui.R.attr.menuTextColor, primary = false, secondary = true, shortnameId = R.string.steps_shortname), @@ -119,9 +120,9 @@ class OverviewMenusImpl @Inject constructor( } else listOf( - arrayOf(true, true, true, false, false, false, false, false, false, false, false, false, false, false), - arrayOf(false, false, false, false, true, false, false, false, false, false, false, false, false, false), - arrayOf(false, false, false, false, false, true, false, false, false, false, false, false, false, false) + arrayOf(true, true, true, false, false, false, false, false, false, false, false, false, false, false, false), + arrayOf(false, false, false, false, true, false, false, false, false, false, false, false, false, false, false), + arrayOf(false, false, false, false, false, true, false, false, false, false, false, false, false, false, false) ) @Synchronized diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/TirHelper.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/TirHelper.kt new file mode 100644 index 00000000000..16c2d40130b --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/TirHelper.kt @@ -0,0 +1,198 @@ +package app.aaps.plugins.main.general.overview + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.profile.ProfileUtil +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.stats.TIR +import app.aaps.core.interfaces.stats.TirCalculator +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.keys.UnitDoubleKey +import app.aaps.plugins.main.R +import app.aaps.plugins.main.general.overview.ui.TirChartData +import app.aaps.plugins.main.general.overview.ui.TirCombinedScenario +import app.aaps.plugins.main.general.overview.ui.TirScenario +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Helper class for TIR (Time In Range) calculations and data preparation + * Provides common functionality for both overview and history screens + */ +@Singleton +class TirHelper @Inject constructor( + private val preferences: Preferences, + private val profileUtil: ProfileUtil, + private val tirCalculator: TirCalculator, + private val dateUtil: DateUtil, + private val rh: ResourceHelper, + private val aapsLogger: AAPSLogger +) { + + /** + * Get validated TIR range preferences in mg/dL + * Converts from user's units (mg/dL or mmol/L) to mg/dL for TirCalculator + * Falls back to defaults if invalid + */ + private fun getValidatedRanges(): Pair { + // Get preferences in user's units (mg/dL or mmol/L) + val lowInUserUnits = preferences.get(UnitDoubleKey.OverviewLowMark) + val highInUserUnits = preferences.get(UnitDoubleKey.OverviewHighMark) + + // Convert to mg/dL (TirCalculator expects mg/dL) + var lowMgdl = profileUtil.convertToMgdl(lowInUserUnits, profileUtil.units) + var highMgdl = profileUtil.convertToMgdl(highInUserUnits, profileUtil.units) + + // Validate and fallback to defaults if invalid + if (lowMgdl < 39.0 || lowMgdl > highMgdl) { + aapsLogger.warn(LTag.UI, "Invalid TIR range preferences: low=$lowMgdl, high=$highMgdl. Using defaults.") + lowMgdl = 72.0 // Default LOW mark (mg/dL) + highMgdl = 180.0 // Default HIGH mark (mg/dL) + } + + return Pair(lowMgdl, highMgdl) + } + + /** + * Calculate TIR data for a specific time range with validated preferences + */ + fun calculateTirForRange(startTime: Long, endTime: Long): TIR { + val (lowMgdl, highMgdl) = getValidatedRanges() + return tirCalculator.calculateRange(startTime, endTime, lowMgdl, highMgdl) + } + + /** + * Calculate TIR data for today with validated preferences + */ + fun calculateTirForToday(): TIR { + val (lowMgdl, highMgdl) = getValidatedRanges() + return tirCalculator.calculateToday(lowMgdl, highMgdl) + } + + /** + * Calculate percentage from count, handling zero total count + * @param count The count value + * @param totalCount Total count (denominator) + * @param scale Optional scaling factor (e.g., elapsedFraction for partial day) + * @return Percentage (0.0 if totalCount is 0) + */ + private fun calculatePct(count: Int, totalCount: Int, scale: Double = 1.0): Double = + if (totalCount > 0) count.toDouble() / totalCount * scale * 100.0 else 0.0 + + /** + * Normalize percentages to ensure they add up to 100% (fix rounding errors). + * Uses an epsilon for floating point comparisons and handles the zero-sum case. + * Distributes any remainder to the largest value. + * @param values Variable number of percentage values + * @return Normalized array where sum equals 100.0 (within epsilon) + */ + private fun normalizePercentages(vararg values: Double): DoubleArray { + val result = values.toList().toDoubleArray() + val sum = result.sum() + val eps = 1e-6 + + // If there's effectively no data (all zeros) just return as-is + if (kotlin.math.abs(sum) <= eps) return result + + // If sum already close to 100, return + if (kotlin.math.abs(sum - 100.0) <= eps) return result + + val remainder = 100.0 - sum + // Add remainder to the largest value + val maxIndex = result.indices.maxByOrNull { result[it] } ?: 0 + result[maxIndex] += remainder + + return result + } + + /** + * Calculate TIR chart data with scenarios for today (main overview screen) + * Returns null if not enough data + */ + fun calculateTodayChartData(): TirChartData? { + val tirData = calculateTirForToday() + val titleText = rh.gs(R.string.tir_today) + + // Calculate elapsed time today and percentages for scenarios + // Handle DST correctly by calculating actual day length + val now = dateUtil.now() + val midnight = app.aaps.core.interfaces.utils.MidnightTime.calc(now) + val nextMidnight = java.time.Instant.ofEpochMilli(midnight) + .atZone(java.time.ZoneId.systemDefault()) + .plusDays(1) + .toInstant() + .toEpochMilli() + val elapsedMs = now - midnight + val totalDayMs = nextMidnight - midnight // Actual day length (handles DST) + val elapsedFraction = elapsedMs.toDouble() / totalDayMs + val remainingFraction = 1.0 - elapsedFraction + + // Current counts + val totalCount = tirData.count + if (totalCount == 0) return null + val belowCount = tirData.below + val inRangeCount = tirData.inRange + val aboveCount = tirData.above + + // Till now: show actual proportions of time spent in each category (not scaled to full day) + // This should fill 100% of the available width, representing elapsed time from midnight till now + val tillNowBelowFull = calculatePct(belowCount, totalCount) + val tillNowInRangeFull = calculatePct(inRangeCount, totalCount) + val tillNowAboveFull = calculatePct(aboveCount, totalCount) + + val (tillNowBelow, tillNowInRange, tillNowAbove) = normalizePercentages( + tillNowBelowFull, + tillNowInRangeFull, + tillNowAboveFull + ) + + // Best case: time so far + remaining time fully in range + val bestBelowFull = calculatePct(belowCount, totalCount, elapsedFraction) + val bestInRangeFull = calculatePct(inRangeCount, totalCount, elapsedFraction) + remainingFraction * 100.0 + val bestAboveFull = calculatePct(aboveCount, totalCount, elapsedFraction) + + val (bestBelow, bestInRange, bestAbove) = normalizePercentages( + bestBelowFull, + bestInRangeFull, + bestAboveFull + ) + + // Worst case: time so far + remaining time marked as unknown (gray) + val worstBelowFull = calculatePct(belowCount, totalCount, elapsedFraction) + val worstInRangeFull = calculatePct(inRangeCount, totalCount, elapsedFraction) + val worstAboveFull = calculatePct(aboveCount, totalCount, elapsedFraction) + val worstUnknownFull = remainingFraction * 100.0 + + val (worstBelow, worstInRange, worstAbove, worstUnknown) = normalizePercentages( + worstBelowFull, + worstInRangeFull, + worstAboveFull, + worstUnknownFull + ) + + val tillNowScenario = TirScenario( + subtitle = rh.gs(R.string.tir_till_now), + belowPct = tillNowBelow, + inRangePct = tillNowInRange, + abovePct = tillNowAbove, + unknownPct = 0.0 // No unknown for till now - should fill 100% width + ) + + val combinedScenario = TirCombinedScenario( + subtitle = rh.gs(R.string.tir_worst_best_case), + belowPct = bestBelow, + abovePct = bestAbove, + worstInRangePct = worstInRange, + bestInRangePct = bestInRange, + worstTotalMiddlePct = worstInRange + worstUnknown + ) + + return TirChartData( + title = titleText, + tillNowScenario = tillNowScenario, + combinedScenario = combinedScenario, + totalCount = totalCount + ) + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/ui/TirChartView.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/ui/TirChartView.kt new file mode 100644 index 00000000000..da828e68cd3 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/overview/ui/TirChartView.kt @@ -0,0 +1,486 @@ +package app.aaps.plugins.main.general.overview.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import androidx.core.content.res.ResourcesCompat +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.stats.TIR +import app.aaps.plugins.main.R +import javax.inject.Inject + +/** + * Data class representing a single TIR scenario row + */ +data class TirScenario( + val subtitle: String, + val belowPct: Double, + val inRangePct: Double, + val abovePct: Double, + val unknownPct: Double = 0.0 // For worst case gray bar +) + +/** + * Data class for combined worst/best case scenario + */ +data class TirCombinedScenario( + val subtitle: String, + val belowPct: Double, // Same for both scenarios + val abovePct: Double, // Same for both scenarios + val worstInRangePct: Double, // In range for worst case + val bestInRangePct: Double, // In range for best case + val worstTotalMiddlePct: Double // worstInRangePct + unknown for worst case +) + +/** + * Data class for TIR chart with multiple scenarios + */ +data class TirChartData( + val title: String, + val tillNowScenario: TirScenario, + val combinedScenario: TirCombinedScenario, + val totalCount: Int // Total number of BG readings +) + +/** + * Custom view for displaying Time In Range (TIR) as a horizontal stacked bar chart + * Supports multiple scenarios (till now, best case, worst case) + */ +class TirChartView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val belowPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val inRangePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val abovePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val unknownPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val subtitlePaint = Paint(Paint.ANTI_ALIAS_FLAG) + + private val barRect = RectF() + private var chartData: TirChartData? = null + private var tirData: TIR? = null + private var title: String = "" + + // Graph drawing parameters + private val barHeight = 20f // dp + private val textSize = 12f // sp + private val subtitleSize = 10f // sp + private val titleSize = 14f // sp + private val horizontalPadding = 16f // dp + private val verticalPadding = 8f // dp + private val titlePadding = 8f // dp + private val subtitlePadding = 4f // dp + private val rowSpacing = 8f // dp (space between scenario rows) + private val labelSpacing = 4f // dp (space between bar and label) + private val separatorWidth = 1f // pixels (transparent gap between bars) + + // Minimum data requirements + private val MINIMUM_REQUIRED_DATA_COUNT: Int = 10 + private val MINIMUM_WORST_IN_RANGE_PCT = 1.0 + + // Helper conversions: use once to produce px values + private fun dp(value: Float): Float = value * resources.displayMetrics.density + private fun sp(value: Float): Float = value * resources.displayMetrics.scaledDensity + + init { + // Convert dp/sp to pixels once (use helpers `dp()` and `sp()` instead of direct access) + + // Initialize paints + val typedValue = android.util.TypedValue() + + // Below range color (red/low) + context.theme.resolveAttribute(app.aaps.core.ui.R.attr.lowColor, typedValue, true) + belowPaint.color = ResourcesCompat.getColor(context.resources, typedValue.resourceId, context.theme) + + // In range color (green) + context.theme.resolveAttribute(app.aaps.core.ui.R.attr.bgInRange, typedValue, true) + inRangePaint.color = ResourcesCompat.getColor(context.resources, typedValue.resourceId, context.theme) + + // Above range color (yellow/high) + context.theme.resolveAttribute(app.aaps.core.ui.R.attr.highColor, typedValue, true) + abovePaint.color = ResourcesCompat.getColor(context.resources, typedValue.resourceId, context.theme) + + // Unknown/gray color for worst case + context.theme.resolveAttribute(app.aaps.core.ui.R.attr.defaultTextColor, typedValue, true) + val textColor = ResourcesCompat.getColor(context.resources, typedValue.resourceId, context.theme) + unknownPaint.color = (textColor and 0x00FFFFFF) or 0x60000000 // Semi-transparent gray + + // Text paint + textPaint.color = textColor + textPaint.textSize = sp(textSize) + textPaint.textAlign = Paint.Align.CENTER + + // Subtitle paint + subtitlePaint.color = textColor + subtitlePaint.textSize = sp(subtitleSize) + subtitlePaint.textAlign = Paint.Align.CENTER + + // Title paint + titlePaint.color = textColor + titlePaint.textSize = sp(titleSize) + titlePaint.textAlign = Paint.Align.CENTER + titlePaint.isFakeBoldText = true + } + + /** + * Set TIR data to be displayed (legacy single-row mode for history dialog) + */ + fun setData(tir: TIR?, titleText: String) { + this.tirData = tir + this.title = titleText + this.chartData = null + invalidate() + } + + /** + * Set TIR chart data with multiple scenarios (for main screen) + */ + fun setChartData(data: TirChartData?) { + this.chartData = data + this.tirData = null + this.title = "" + invalidate() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val desiredHeight = if (chartData != null) { + // Multi-scenario mode: title + till now row + combined row (same height as till now) + val normalRowHeight = sp(subtitleSize) + dp(subtitlePadding) + + dp(barHeight) + dp(labelSpacing) + sp(textSize) + (sp(titleSize) + dp(titlePadding) + + normalRowHeight * 2 + dp(rowSpacing) + + dp(verticalPadding) * 2).toInt() + } else { + // Single row mode: title + titlePadding + bar + labelSpacing + text + verticalPadding + (sp(titleSize) + dp(titlePadding) + dp(barHeight) + + dp(labelSpacing) + sp(textSize) + dp(verticalPadding) * 2).toInt() + } + + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + + val height = when (heightMode) { + MeasureSpec.EXACTLY -> heightSize + MeasureSpec.AT_MOST -> desiredHeight.coerceAtMost(heightSize) + else -> desiredHeight + } + + setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), height) + } + + private fun drawScenarioBar( + canvas: Canvas, + barTop: Float, + barBottom: Float, + barWidth: Float, + hPadding: Float, + labelY: Float, + belowPct: Double, + inRangePct: Double, + abovePct: Double, + unknownPct: Double = 0.0 + ) { + // Normalize displayed percentages to ensure they add up to 100% + var displayBelow = kotlin.math.round(belowPct).toInt() + var displayInRange = kotlin.math.round(inRangePct).toInt() + var displayAbove = kotlin.math.round(abovePct).toInt() + var displayUnknown = kotlin.math.round(unknownPct).toInt() + + val sum = displayBelow + displayInRange + displayAbove + displayUnknown + if (sum != 100 && sum > 0) { + val remainder = 100 - sum + // Add remainder to largest value + when { + displayInRange >= displayBelow && displayInRange >= displayAbove && displayInRange >= displayUnknown -> displayInRange += remainder + displayBelow >= displayAbove && displayBelow >= displayUnknown -> displayBelow += remainder + displayAbove >= displayUnknown -> displayAbove += remainder + else -> displayUnknown += remainder + } + } + + // Count non-zero segments to calculate separators needed + val nonZeroSegments = listOf(belowPct > 0, inRangePct > 0, abovePct > 0, unknownPct > 0).count { it } + val totalSeparatorWidth = if (nonZeroSegments > 1) (nonZeroSegments - 1) * separatorWidth else 0f + val availableBarWidth = barWidth - totalSeparatorWidth + + var currentX = hPadding + var isFirstSegment = true + + // Draw below range bar + if (belowPct > 0) { + val segmentWidth = (availableBarWidth * belowPct / 100f).toFloat().coerceAtLeast(1f) + barRect.set(currentX, barTop, currentX + segmentWidth, barBottom) + canvas.drawRect(barRect, belowPaint) + + val textX = currentX + segmentWidth / 2f + canvas.drawText(String.format("%d%%", displayBelow), textX, labelY, textPaint) + + currentX += segmentWidth + isFirstSegment = false + } + + // Draw in range bar + if (inRangePct > 0) { + if (!isFirstSegment) currentX += separatorWidth + val segmentWidth = (availableBarWidth * inRangePct / 100f).toFloat().coerceAtLeast(1f) + barRect.set(currentX, barTop, currentX + segmentWidth, barBottom) + canvas.drawRect(barRect, inRangePaint) + + val textX = currentX + segmentWidth / 2f + canvas.drawText(String.format("%d%%", displayInRange), textX, labelY, textPaint) + + currentX += segmentWidth + isFirstSegment = false + } + + // Draw above range bar + if (abovePct > 0) { + if (!isFirstSegment) currentX += separatorWidth + val segmentWidth = (availableBarWidth * abovePct / 100f).toFloat().coerceAtLeast(1f) + barRect.set(currentX, barTop, currentX + segmentWidth, barBottom) + canvas.drawRect(barRect, abovePaint) + + val textX = currentX + segmentWidth / 2f + canvas.drawText(String.format("%d%%", displayAbove), textX, labelY, textPaint) + + currentX += segmentWidth + isFirstSegment = false + } + + // Draw unknown (gray) bar for worst case + if (unknownPct > 0) { + if (!isFirstSegment) currentX += separatorWidth + val segmentWidth = (availableBarWidth * unknownPct / 100f).toFloat().coerceAtLeast(1f) + barRect.set(currentX, barTop, currentX + segmentWidth, barBottom) + canvas.drawRect(barRect, unknownPaint) + + val textX = currentX + segmentWidth / 2f + canvas.drawText(String.format("%d%%", displayUnknown), textX, labelY, textPaint) + } + } + + private fun drawCombinedScenarioBar( + canvas: Canvas, + barTop: Float, + barHeight: Float, + barWidth: Float, + hPadding: Float, + labelY: Float, + combined: TirCombinedScenario + ) { + val barBottom = barTop + barHeight + + // Normalize displayed percentages to ensure they add up to 100% + // Round to integers first + var displayBelow = kotlin.math.round(combined.belowPct).toInt() + var displayBestInRange = kotlin.math.round(combined.bestInRangePct).toInt() + var displayAbove = kotlin.math.round(combined.abovePct).toInt() + var displayWorstInRange = kotlin.math.round(combined.worstInRangePct).toInt() + + // Normalize best case (below + bestInRange + above = 100) + val bestSum = displayBelow + displayBestInRange + displayAbove + if (bestSum != 100) { + val remainder = 100 - bestSum + // Add remainder to largest value + when { + displayBestInRange >= displayBelow && displayBestInRange >= displayAbove -> displayBestInRange += remainder + displayBelow >= displayAbove -> displayBelow += remainder + else -> displayAbove += remainder + } + } + + // Section proportions: 1 : 1.5 : 1 (top : middle : bottom) + // Divide by 3.5 (1 + 1.5 + 1) for top/bottom sections + val topBottomSectionHeight = (barHeight / 3.5f) + val middleSectionHeight = barHeight - (topBottomSectionHeight * 2) + + // Calculate positions with separators + val nonZeroSegments = listOf(combined.belowPct > 0, true, combined.abovePct > 0).count { it } // Middle always exists + val totalSeparatorWidth = if (nonZeroSegments > 1) (nonZeroSegments - 1) * separatorWidth else 0f + val availableBarWidth = barWidth - totalSeparatorWidth + + var currentX = hPadding + + // Draw below range bar (if exists) + val belowWidth = if (combined.belowPct > 0) { + (availableBarWidth * combined.belowPct / 100f).toFloat().coerceAtLeast(1f) + } else 0f + + if (belowWidth > 0) { + barRect.set(currentX, barTop, currentX + belowWidth, barBottom) + canvas.drawRect(barRect, belowPaint) + + val textX = currentX + belowWidth / 2f + canvas.drawText(String.format("%d%%", displayBelow), textX, labelY, textPaint) + + currentX += belowWidth + separatorWidth + } + + // Calculate middle section width + val aboveWidth = if (combined.abovePct > 0) { + (availableBarWidth * combined.abovePct / 100f).toFloat().coerceAtLeast(1f) + } else 0f + val middleWidth = availableBarWidth - belowWidth - aboveWidth + + // Draw complex middle bar + if (middleWidth > 0) { + val middleLeft = currentX + val middleRight = currentX + middleWidth + + // Calculate section boundaries + val topY = barTop + val topBottomY = barTop + topBottomSectionHeight + val bottomY = barBottom - topBottomSectionHeight + val midTopY = topBottomY + val midBottomY = bottomY + + // Calculate green bar widths for top (worst) and bottom (best) + val worstGreenWidth = (middleWidth * combined.worstInRangePct / combined.worstTotalMiddlePct).toFloat() + val worstGreenLeft = middleLeft + (middleWidth - worstGreenWidth) / 2f + + // STEP 1: Draw gray background for all three sections + barRect.set(middleLeft, topY, middleRight, barBottom) + canvas.drawRect(barRect, unknownPaint) + + // STEP 2: Draw green elements on top with slight overlaps to avoid rounding gaps + val overlap = 1f // 1 pixel overlap to cover fractional pixel gaps + + // Bottom section (best case): full width green bar, extend upward slightly + barRect.set(middleLeft, bottomY - overlap, middleRight, barBottom) + canvas.drawRect(barRect, inRangePaint) + + // Middle section (transition): green trapezoid connecting top to bottom + val path = android.graphics.Path() + path.moveTo(worstGreenLeft, midTopY) // Top left + path.lineTo(worstGreenLeft + worstGreenWidth, midTopY) // Top right + path.lineTo(middleRight, midBottomY) // Bottom right + path.lineTo(middleLeft, midBottomY) // Bottom left + path.close() + canvas.drawPath(path, inRangePaint) + + // Top section (worst case): centered green bar, extend downward slightly + barRect.set(worstGreenLeft, topY, worstGreenLeft + worstGreenWidth, topBottomY + overlap) + canvas.drawRect(barRect, inRangePaint) + + // Draw label with range + val textX = middleLeft + middleWidth / 2f + val labelText = String.format("%d%% .. %d%%", displayWorstInRange, displayBestInRange) + canvas.drawText(labelText, textX, labelY, textPaint) + + currentX += middleWidth + } + + // Draw above range bar (if exists) + if (aboveWidth > 0) { + currentX += separatorWidth + barRect.set(currentX, barTop, currentX + aboveWidth, barBottom) + canvas.drawRect(barRect, abovePaint) + + val textX = currentX + aboveWidth / 2f + canvas.drawText(String.format("%d%%", displayAbove), textX, labelY, textPaint) + } + } + + override fun onDraw(canvas: Canvas) { + + val hPadding = dp(horizontalPadding) + val vPadding = dp(verticalPadding) + val tPadding = dp(titlePadding) + val sPadding = dp(subtitlePadding) + val rSpacing = dp(rowSpacing) + val bHeight = dp(barHeight) + val lSpacing = dp(labelSpacing) + val barWidth = width - 2 * hPadding + + // Multi-scenario mode (main screen with till now + combined best/worst) + if (chartData != null) { + val data = chartData!! + + // Draw main title + var currentY = vPadding + sp(titleSize) + canvas.drawText(data.title, width / 2f, currentY, titlePaint) + currentY += tPadding + + // Check if we should show "not enough data" message + val notEnoughData = data.totalCount < MINIMUM_REQUIRED_DATA_COUNT || + data.combinedScenario.worstInRangePct <= MINIMUM_WORST_IN_RANGE_PCT + + if (notEnoughData) { + // Show centered "not enough data" message + val messageY = vPadding + (height - vPadding * 2) / 2f + val message = context.getString(R.string.tir_not_enough_data) + canvas.drawText(message, width / 2f, messageY, textPaint) + return + } + + // Draw "till now" scenario + currentY += sp(subtitleSize) + canvas.drawText(data.tillNowScenario.subtitle, width / 2f, currentY, subtitlePaint) + + currentY += sPadding + + val tillNowBarTop = currentY + val tillNowBarBottom = tillNowBarTop + bHeight + val tillNowLabelY = tillNowBarBottom + lSpacing + sp(textSize) + + drawScenarioBar( + canvas, tillNowBarTop, tillNowBarBottom, barWidth, hPadding, tillNowLabelY, + data.tillNowScenario.belowPct, data.tillNowScenario.inRangePct, data.tillNowScenario.abovePct, 0.0 + ) + + currentY = tillNowLabelY + rSpacing + + // Draw combined worst/best scenario (same height as till now) + currentY += sp(subtitleSize) + canvas.drawText(data.combinedScenario.subtitle, width / 2f, currentY, subtitlePaint) + + currentY += sPadding + + val combinedBarTop = currentY + val combinedBarHeight = bHeight + val combinedLabelY = combinedBarTop + combinedBarHeight + lSpacing + sp(textSize) + + drawCombinedScenarioBar( + canvas, combinedBarTop, combinedBarHeight, barWidth, hPadding, combinedLabelY, + data.combinedScenario + ) + + return + } + + // Single row mode (history dialog) + val tir = tirData + if (tir == null || tir.count == 0) { + if (title.isNotEmpty()) { + val titleY = height / 2f + canvas.drawText(title, width / 2f, titleY, titlePaint) + } + return + } + + val belowPct = if (tir.count > 0) tir.below.toDouble() / tir.count * 100.0 else 0.0 + val inRangePct = if (tir.count > 0) tir.inRange.toDouble() / tir.count * 100.0 else 0.0 + val abovePct = if (tir.count > 0) tir.above.toDouble() / tir.count * 100.0 else 0.0 + + val titleY = vPadding + sp(titleSize) + if (title.isNotEmpty()) { + canvas.drawText(title, width / 2f, titleY, titlePaint) + } + + val barTop = titleY + tPadding + val barBottom = barTop + bHeight + val labelY = barBottom + lSpacing + sp(textSize) + + drawScenarioBar( + canvas, barTop, barBottom, barWidth, hPadding, labelY, + belowPct, inRangePct, abovePct, 0.0 + ) + } +} diff --git a/plugins/main/src/main/res/layout/overview_graphs_layout.xml b/plugins/main/src/main/res/layout/overview_graphs_layout.xml index 3f9171b430c..aebb592f3f3 100644 --- a/plugins/main/src/main/res/layout/overview_graphs_layout.xml +++ b/plugins/main/src/main/res/layout/overview_graphs_layout.xml @@ -48,4 +48,8 @@ android:layout_height="wrap_content" android:orientation="vertical" /> + + \ No newline at end of file diff --git a/plugins/main/src/main/res/layout/overview_tir_chart.xml b/plugins/main/src/main/res/layout/overview_tir_chart.xml new file mode 100644 index 00000000000..2d12722ff1d --- /dev/null +++ b/plugins/main/src/main/res/layout/overview_tir_chart.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/plugins/main/src/main/res/values/strings.xml b/plugins/main/src/main/res/values/strings.xml index 91bfc9c8dc2..809e77100c0 100644 --- a/plugins/main/src/main/res/values/strings.xml +++ b/plugins/main/src/main/res/values/strings.xml @@ -290,6 +290,7 @@ Basals Absolute insulin Variable sensitivity + Time In Range PRED BAS DEV @@ -325,4 +326,12 @@ STEPS Simple mode enabled + + TIR + Time In Range (today) + Time In Range (shown time) + till now + worst and best case + not enough data + diff --git a/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/overview/TirHelperTest.kt b/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/overview/TirHelperTest.kt new file mode 100644 index 00000000000..21b97d50d7c --- /dev/null +++ b/plugins/main/src/test/kotlin/app/aaps/plugins/main/general/overview/TirHelperTest.kt @@ -0,0 +1,214 @@ +package app.aaps.plugins.main.general.overview + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.profile.ProfileUtil +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.stats.TIR +import app.aaps.core.interfaces.stats.TirCalculator +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.interfaces.utils.MidnightTime +import app.aaps.plugins.main.general.overview.TirHelper +import app.aaps.plugins.main.general.overview.ui.TirChartData +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.whenever + +class TirHelperTest { + + @Mock + lateinit var preferences: app.aaps.core.keys.interfaces.Preferences + @Mock + lateinit var profileUtil: ProfileUtil + @Mock + lateinit var tirCalculator: TirCalculator + @Mock + lateinit var dateUtil: DateUtil + @Mock + lateinit var rh: ResourceHelper + @Mock + lateinit var aapsLogger: AAPSLogger + + private lateinit var sut: TirHelper + + @BeforeEach + fun setup() { + MockitoAnnotations.openMocks(this) + sut = TirHelper(preferences, profileUtil, tirCalculator, dateUtil, rh, aapsLogger) + // ResourceHelper.gs is used to build subtitles/title; ensure it returns non-null strings in tests + whenever(rh.gs(org.mockito.kotlin.any())).thenReturn("TIR") + } + + @Test + fun `returns null when no readings`() { + val emptyTir = TestTIR(date = 0L) + + whenever(tirCalculator.calculateToday(anyDouble(), anyDouble())).thenReturn(emptyTir) + whenever(dateUtil.now()).thenReturn(1000000000L) + + val result = sut.calculateTodayChartData() + + assertThat(result).isNull() + } + + @Test + fun `calculates time based percentages and sums to 100`() { + val tir = TestTIR(date = 0L, lowThreshold = 70.0, highThreshold = 180.0, + count = 4, below = 1, inRange = 2, above = 1, error = 0) + + // Simulate half of day elapsed (tests compute midnight deterministically) + val now = 1000000000L + val midnight = MidnightTime.calc(now) + whenever(dateUtil.now()).thenReturn(now) + whenever(tirCalculator.calculateToday(anyDouble(), anyDouble())).thenReturn(tir) + + val chartData = sut.calculateTodayChartData() + + assertThat(chartData).isNotNull() + val combined = chartData as TirChartData + + // Check totalCount propagated + assertThat(combined.totalCount).isEqualTo(4) + + // Verify tillNow sums to ~100 (no unknown, just actual proportions) + val t = combined.tillNowScenario + val sum = t.belowPct + t.inRangePct + t.abovePct + assertThat(Math.round(sum)).isEqualTo(100) + assertThat(t.unknownPct).isEqualTo(0.0) + } + + @Test + fun `best and worst scenarios produce expected relationships`() { + // 4 readings: below=1, inRange=2, above=1 -> 25%,50%,25% of observed time + val tir = TestTIR(date = 0L, lowThreshold = 70.0, highThreshold = 180.0, + count = 4, below = 1, inRange = 2, above = 1, error = 0) + + // Use deterministic midnight calculation for given instant + val now = 1000000000L + val midnight = MidnightTime.calc(now) + whenever(dateUtil.now()).thenReturn(now) + whenever(tirCalculator.calculateToday(anyDouble(), anyDouble())).thenReturn(tir) + + val chartData = sut.calculateTodayChartData()!! + + // Till now: actual proportions of observed readings (should sum to 100%, no unknown) + val t = chartData.tillNowScenario + assertThat(t.unknownPct).isEqualTo(0.0) + // inRange should be ~ 50% (2 out of 4 readings) + assertThat(Math.round(t.inRangePct)).isEqualTo(50) + assertThat(t.inRangePct).isGreaterThan(t.belowPct) + assertThat(t.inRangePct).isGreaterThan(t.abovePct) + + // Combined best case: inRange includes remaining time -> should be > tillNow inRange (50%) + val bestInRange = chartData.combinedScenario.bestInRangePct + assertThat(bestInRange).isGreaterThan(t.inRangePct) + + // Combined worst case: worstInRange should be less than tillNow inRange + // (since remaining time is unknown, only observed inRange counts) + val worstInRange = chartData.combinedScenario.worstInRangePct + assertThat(worstInRange).isLessThan(t.inRangePct) + } + + @Test + fun `handles very small counts without throwing and normalizes correctly`() { + // Only 1 reading (inRange). + val tir = TestTIR(date = 0L, lowThreshold = 70.0, highThreshold = 180.0, + count = 1, below = 0, inRange = 1, above = 0, error = 0) + + val now = 1000000000L + val midnight = MidnightTime.calc(now) + whenever(dateUtil.now()).thenReturn(now) + whenever(tirCalculator.calculateToday(anyDouble(), anyDouble())).thenReturn(tir) + + val chartData = sut.calculateTodayChartData() + assertThat(chartData).isNotNull() + + val t = chartData!!.tillNowScenario + // With single reading inRange, it should be 100% (no unknown, just actual proportion) + assertThat(Math.round(t.inRangePct)).isEqualTo(100) + assertThat(t.unknownPct).isEqualTo(0.0) + assertThat(t.belowPct).isEqualTo(0.0) + assertThat(t.abovePct).isEqualTo(0.0) + } + + @Test + fun `dst day length variation produces consistent sums`() { + // Use deterministic counts + val tir = TestTIR(date = 0L, lowThreshold = 70.0, highThreshold = 180.0, + count = 10, below = 2, inRange = 6, above = 2, error = 0) + + // Simulate a short day (23 hours) where elapsed is 12 hours -> elapsedFraction > 0.5 + val now = 1_000_000_000_000L + whenever(dateUtil.now()).thenReturn(now) + whenever(tirCalculator.calculateToday(anyDouble(), anyDouble())).thenReturn(tir) + + // Call twice to ensure normalization doesn't depend on exact day length calculation beyond elapsed/remaining + val chart1 = sut.calculateTodayChartData() + val chart2 = sut.calculateTodayChartData() + + assertThat(chart1).isNotNull() + assertThat(chart2).isNotNull() + + // Till now should sum to 100%, no unknown + val sum1 = chart1!!.tillNowScenario.run { belowPct + inRangePct + abovePct } + val sum2 = chart2!!.tillNowScenario.run { belowPct + inRangePct + abovePct } + + assertThat(Math.round(sum1)).isEqualTo(100) + assertThat(Math.round(sum2)).isEqualTo(100) + assertThat(chart1.tillNowScenario.unknownPct).isEqualTo(0.0) + assertThat(chart2.tillNowScenario.unknownPct).isEqualTo(0.0) + } + + @Test + fun `deterministic expected percentages for known elapsed fraction`() { + // 2 readings: below=0, inRange=1, above=1 -> observed 0%,50%,50% + val tir = TestTIR(date = 0L, lowThreshold = 70.0, highThreshold = 180.0, + count = 2, below = 0, inRange = 1, above = 1, error = 0) + + // Use deterministic midnight calculation for given instant + val now = 1_234_567_890_000L + val midnight = MidnightTime.calc(now) + whenever(dateUtil.now()).thenReturn(now) + whenever(tirCalculator.calculateToday(anyDouble(), anyDouble())).thenReturn(tir) + + val chart = sut.calculateTodayChartData()!! + val t = chart.tillNowScenario + + // No unknown, just actual proportions + assertThat(t.unknownPct).isEqualTo(0.0) + // The two observed categories should be equal (both came from 1 reading each = 50%) + assertThat(Math.round(t.inRangePct)).isEqualTo(50) + assertThat(Math.round(t.abovePct)).isEqualTo(50) + assertThat(t.belowPct).isEqualTo(0.0) + } +} + +// Small helpers for Mockito any matchers since we used kotlin mockito +private fun anyDouble(): Double = org.mockito.kotlin.any() + +// Simple mutable implementation of TIR for tests +private class TestTIR( + override var date: Long = 0L, + override var lowThreshold: Double = 0.0, + override var highThreshold: Double = 0.0, + override var count: Int = 0, + override var below: Int = 0, + override var inRange: Int = 0, + override var above: Int = 0, + override var error: Int = 0 +) : TIR { + override fun error() { this.error += 1 } + override fun below() { this.below += 1; this.count += 1 } + override fun inRange() { this.inRange += 1; this.count += 1 } + override fun above() { this.above += 1; this.count += 1 } + + override fun toTableRow(context: android.content.Context, rh: ResourceHelper, dateUtil: DateUtil): android.widget.TableRow { + return android.widget.TableRow(context) + } + + override fun toTableRow(context: android.content.Context, rh: ResourceHelper, days: Int): android.widget.TableRow { + return android.widget.TableRow(context) + } +}