diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt b/core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt index 9ec87d25c7dd..bc755baa0f5e 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt +++ b/core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt @@ -185,6 +185,15 @@ enum class IntKey( defaultedBySM = true, unitType = UnitType.PERCENT ), + LoopMinBgRecalcInterval( + key = "loop_min_bg_recalc_interval", + defaultValue = 0, + min = 0, + max = 5, + titleResId = R.string.pref_title_loop_min_bg_recalc_interval, + summaryResId = R.string.loop_min_bg_recalc_interval_summary, + unitType = UnitType.MIN + ), ApsMaxSmbFrequency(key = "smbinterval", defaultValue = 3, min = 1, max = 10, titleResId = R.string.pref_title_smb_frequency, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb, unitType = UnitType.MIN), ApsMaxMinutesOfBasalToLimitSmb(key = "smbmaxminutes", defaultValue = 30, min = 15, max = 120, titleResId = R.string.pref_title_smb_max_minutes, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb, unitType = UnitType.MIN), ApsUamMaxMinutesOfBasalToLimitSmb( diff --git a/core/keys/src/main/res/values/strings.xml b/core/keys/src/main/res/values/strings.xml index 2cf0d59f05de..1f5fcee5835d 100644 --- a/core/keys/src/main/res/values/strings.xml +++ b/core/keys/src/main/res/values/strings.xml @@ -212,6 +212,8 @@ Open mode minimum change Open Loop will popup new change request only if change is bigger than this value in %. Default value is 20% + Loop frequency limit + Minimum minutes between loop iterations triggered by new BG readings. AAPS was designed for 5-minute CGM intervals — sensors that report more frequently can cause hundreds of extra loop cycles per day, increasing battery usage. 0 disables the limit. SMB interval diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt index 2072f7afafd0..330142e560f8 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt +++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt @@ -18,6 +18,7 @@ import app.aaps.core.keys.interfaces.PreferenceVisibilityContext * Supported types: * - PreferenceKey: Rendered with AdaptivePreferenceItem * - PreferenceSubScreenDef: Rendered as navigation item + * - CustomPreferenceItem: Rendered via its Content() composable * * @param items List of PreferenceItems to render * @param visibilityContext Optional context for evaluating runtime visibility conditions @@ -46,6 +47,8 @@ fun AdaptivePreferenceList( // Subscreens are handled by PreferenceContentExtensions as nested collapsible sections // Not rendered here in the flat list } + + is CustomPreferenceItem -> item.Content() } } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/CustomPreferenceItem.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/CustomPreferenceItem.kt new file mode 100644 index 000000000000..9191e3803b55 --- /dev/null +++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/CustomPreferenceItem.kt @@ -0,0 +1,16 @@ +package app.aaps.core.ui.compose.preference + +import androidx.compose.runtime.Composable +import app.aaps.core.keys.interfaces.PreferenceItem + +/** + * Extension point for plugin-defined preference rows that need behavior beyond what + * [PreferenceKey][app.aaps.core.keys.interfaces.PreferenceKey] can express — e.g., live + * data alongside an editable value. Subclass and override [Content] to render a row + * that the framework will place inline among regular preferences. + */ +interface CustomPreferenceItem : PreferenceItem { + + @Composable + fun Content() +} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt index 7d3a7579bb21..6b59e0d43e9e 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt +++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt @@ -96,6 +96,8 @@ private fun RenderPreferenceItems( } } + is CustomPreferenceItem -> item.Content() + is PreferenceSubScreenDef -> { val shouldShow = shouldShowSubScreenInline( subScreen = item, diff --git a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopIntervalPreferenceItem.kt b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopIntervalPreferenceItem.kt new file mode 100644 index 000000000000..13377a2f6f4a --- /dev/null +++ b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopIntervalPreferenceItem.kt @@ -0,0 +1,112 @@ +package app.aaps.plugins.aps.loop + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.keys.IntKey +import app.aaps.core.keys.unitLabelResId +import app.aaps.core.keys.valueResId +import app.aaps.core.ui.compose.preference.CustomPreferenceItem +import app.aaps.core.ui.compose.preference.LocalPreferenceTheme +import app.aaps.core.ui.compose.preference.PreferenceSliderWithButtons +import app.aaps.core.ui.compose.preference.rememberPreferenceIntState +import app.aaps.plugins.aps.R +import java.text.DecimalFormat + +/** + * Loop frequency-limit slider with two live readouts beneath it: + * - selected-mode estimate (e.g. "~480 loops/day" or "every reading") + * - actual loops in the last 24h, escalating to a warning when usage is high or + * the user picks a near-1-minute floor. + * + * Pure-key [IntPreferenceKey] rendering can't show those without coupling the + * framework to APS data, hence the [CustomPreferenceItem] subclass. + */ +class LoopIntervalPreferenceItem( + private val persistenceLayer: PersistenceLayer +) : CustomPreferenceItem { + + @Composable + override fun Content() { + val intKey = IntKey.LoopMinBgRecalcInterval + val state = rememberPreferenceIntState(intKey) + val value = state.value + val theme = LocalPreferenceTheme.current + + var dailyCount by remember { mutableStateOf(null) } + // Re-query on each selection change so the warning state stays in sync after the + // user nudges the slider; the count itself doesn't depend on `value`, but recompute + // is cheap and keeps the estimate/warning evaluation paired. + LaunchedEffect(value) { + val now = System.currentTimeMillis() + dailyCount = persistenceLayer.getApsResults(now - 24 * 60 * 60 * 1000L, now).size + } + + val unitLabelResId = intKey.unitType.unitLabelResId() ?: 0 + val valueFormatResId = intKey.unitType.valueResId() + val title = stringResource(intKey.titleResId) + val summary = intKey.summaryResId?.takeIf { it != 0 }?.let { stringResource(it) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(theme.listItemPadding) + ) { + Text(text = title, style = theme.titleTextStyle, color = theme.titleColor) + if (summary != null) { + Text(text = summary, style = theme.summaryTextStyle, color = theme.summaryColor) + } + + PreferenceSliderWithButtons( + value = value.toDouble(), + onValueChange = { newValue -> state.value = newValue.toInt() }, + valueRange = intKey.min.toDouble()..intKey.max.toDouble(), + step = 1.0, + showValue = true, + valueFormatResId = valueFormatResId, + formatAsInt = true, + valueFormat = DecimalFormat("0"), + unitLabelResId = unitLabelResId, + dialogLabel = title, + dialogSummary = summary + ) + + val estimate = if (value == 0) stringResource(R.string.loop_recalc_every_sensor_reading) + else stringResource(R.string.loop_recalc_loops_per_day, 1440 / value) + Text( + text = estimate, + style = theme.summaryTextStyle, + fontWeight = FontWeight.SemiBold, + color = theme.titleColor, + modifier = Modifier.padding(top = 4.dp) + ) + + dailyCount?.let { count -> + val (text, isWarning) = when { + count > 500 -> stringResource(R.string.loop_recalc_daily_count_warning, count) to true + value in 1..2 -> stringResource(R.string.loop_recalc_high_rate_warning) to true + else -> stringResource(R.string.loop_recalc_daily_count, count) to false + } + Text( + text = text, + style = theme.summaryTextStyle, + color = if (isWarning) MaterialTheme.colorScheme.error else theme.summaryColor, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + } +} diff --git a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt index c9692da899e5..a1ab2f8ba128 100644 --- a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt +++ b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt @@ -1011,7 +1011,8 @@ class LoopPlugin @Inject constructor( key = "loop_settings", titleResId = app.aaps.core.ui.R.string.loop, items = listOf( - IntKey.LoopOpenModeMinChange + IntKey.LoopOpenModeMinChange, + LoopIntervalPreferenceItem(persistenceLayer) ), icon = pluginDescription.icon ) diff --git a/plugins/aps/src/main/res/values/strings.xml b/plugins/aps/src/main/res/values/strings.xml index eaa13afb9119..acf60df46521 100644 --- a/plugins/aps/src/main/res/values/strings.xml +++ b/plugins/aps/src/main/res/values/strings.xml @@ -69,6 +69,11 @@ SMB request time SMB execution time SMB set by pump + Recalculates every time a sensor reading arrives + ~%1$d loops/day + Actual usage: %1$d loops in the last 24h + Actual usage: %1$d loops in the last 24h. This may excessively drain battery; a higher frequency limit is recommended. + May noticeably reduce battery life. Fallback to SMB. Not enough TDD data. Fallback to profile sensitivity. Not enough data. Reason: %1$s diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPlugin.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPlugin.kt index 80e9464a88f8..6898affca7dd 100644 --- a/plugins/main/src/main/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPlugin.kt +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPlugin.kt @@ -447,8 +447,11 @@ class IobCobCalculatorPlugin @Inject constructor( private var scheduledData: ScheduledHistoryData? = null + private var lastBgCalcTriggeredAt: Long = 0L + @Synchronized fun scheduleHistoryDataChange(oldDataTimestamp: Long, reloadBgData: Boolean, triggeredByNewBG: Boolean = false) { + if (triggeredByNewBG && shouldThrottleBgTriggeredRecalc()) return // if there is nothing scheduled or asking reload deeper to the past if (scheduledData == null || oldDataTimestamp < (scheduledData?.oldDataTimestamp ?: 0L)) { // cancel waiting task to prevent sending multiple posts @@ -478,6 +481,20 @@ class IobCobCalculatorPlugin @Inject constructor( } } + private fun shouldThrottleBgTriggeredRecalc(): Boolean { + val intervalMinutes = preferences.get(IntKey.LoopMinBgRecalcInterval) + if (intervalMinutes <= 0) return false + val now = dateUtil.now() + val intervalMs = intervalMinutes * 60 * 1000L - 10_000L + val timeSinceLastCalc = now - lastBgCalcTriggeredAt + if (timeSinceLastCalc < intervalMs) { + aapsLogger.debug(LTag.AUTOSENS, "Throttled BG-triggered recalc: ${timeSinceLastCalc / 1000}s since last, min=${intervalMs / 1000}s") + return true + } + lastBgCalcTriggeredAt = now + return false + } + // When historical data is changed (coming from NS etc.) finished calculations after this date must be invalidated private fun newHistoryData(oldDataTimestamp: Long, bgDataReload: Boolean, triggeredByNewBG: Boolean) { calculationWorkflow.stopCalculation(CalculationWorkflow.MAIN_CALCULATION, "onEventNewHistoryData") diff --git a/plugins/main/src/test/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPluginThrottleTest.kt b/plugins/main/src/test/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPluginThrottleTest.kt new file mode 100644 index 000000000000..78245637aeb4 --- /dev/null +++ b/plugins/main/src/test/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPluginThrottleTest.kt @@ -0,0 +1,152 @@ +package app.aaps.plugins.main.iob.iobCobCalculator + +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.db.ProcessedTbrEbData +import app.aaps.core.interfaces.overview.OverviewData +import app.aaps.core.interfaces.overview.graph.OverviewDataCache +import app.aaps.core.interfaces.plugin.ActivePlugin +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.interfaces.utils.DecimalFormatter +import app.aaps.core.interfaces.utils.fabric.FabricPrivacy +import app.aaps.core.interfaces.workflow.CalculationSignalsEmitter +import app.aaps.core.interfaces.workflow.CalculationWorkflow +import app.aaps.core.keys.IntKey +import app.aaps.core.keys.interfaces.Preferences +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.mockito.Mock +import org.mockito.kotlin.whenever +import javax.inject.Provider + +class IobCobCalculatorPluginThrottleTest : TestBase() { + + @Mock lateinit var preferences: Preferences + @Mock lateinit var rh: ResourceHelper + @Mock lateinit var profileFunction: ProfileFunction + @Mock lateinit var activePlugin: ActivePlugin + @Mock lateinit var fabricPrivacy: FabricPrivacy + @Mock lateinit var persistenceLayer: PersistenceLayer + @Mock lateinit var overviewData: OverviewData + @Mock lateinit var calculationWorkflow: CalculationWorkflow + @Mock lateinit var decimalFormatter: DecimalFormatter + @Mock lateinit var processedTbrEbData: ProcessedTbrEbData + @Mock lateinit var signals: CalculationSignalsEmitter + @Mock lateinit var cache: Provider + @Mock lateinit var dateUtil: DateUtil + + private lateinit var sut: IobCobCalculatorPlugin + + private fun getLastBgCalcTriggeredAt(): Long { + val field = IobCobCalculatorPlugin::class.java.getDeclaredField("lastBgCalcTriggeredAt") + field.isAccessible = true + return field.getLong(sut) + } + + private fun setLastBgCalcTriggeredAt(value: Long) { + val field = IobCobCalculatorPlugin::class.java.getDeclaredField("lastBgCalcTriggeredAt") + field.isAccessible = true + field.setLong(sut, value) + } + + @BeforeEach + fun setup() { + sut = IobCobCalculatorPlugin( + aapsLogger, aapsSchedulers, rxBus, preferences, rh, + profileFunction, activePlugin, fabricPrivacy, dateUtil, + persistenceLayer, overviewData, calculationWorkflow, + decimalFormatter, processedTbrEbData, signals, cache + ) + } + + @Test + fun `skips throttle check when interval is disabled`() { + val now = 1_700_000_000_000L + whenever(dateUtil.now()).thenReturn(now) + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(0) + val priorTimestamp = now - 1000L + setLastBgCalcTriggeredAt(priorTimestamp) + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(priorTimestamp) + } + + @Test + fun `skips throttle check for non-BG events with 3 minute interval`() { + val now = 1_700_000_000_000L + whenever(dateUtil.now()).thenReturn(now) + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + val lastCalcTime = now - 1000L + setLastBgCalcTriggeredAt(lastCalcTime) + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = false, triggeredByNewBG = false) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(lastCalcTime) + } + + @Test + fun `does not throttle on first BG event with interval of 3 minutes`() { + val now = 1_700_000_000_000L + whenever(dateUtil.now()).thenReturn(now) + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + val priorTimestamp = 0L + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(now) + } + + @Test + fun `does not throttle when 4 minutes have elapsed with 3 minute interval`() { + val now = 1_700_000_000_000L + whenever(dateUtil.now()).thenReturn(now) + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + setLastBgCalcTriggeredAt(now - 4 * 60 * 1000L) + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(now) + } + + @Test + fun `does not throttle when 2 minutes 55 seconds have elapsed with 3 minute interval including grace period`() { + val now = 1_700_000_000_000L + whenever(dateUtil.now()).thenReturn(now) + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + setLastBgCalcTriggeredAt(now - (2 * 60 * 1000L + 55 * 1000L)) + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(now) + } + + @Test + fun `throttles when BG event arrives immediately with 3 minute interval`() { + val now = 1_700_000_000_000L + whenever(dateUtil.now()).thenReturn(now) + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + val priorTimestamp = now + setLastBgCalcTriggeredAt(priorTimestamp) + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(priorTimestamp) + } + + @Test + fun `throttles when BG events occur 30 seconds apart with 1 minute interval`() { + val now = 1_700_000_000_000L + whenever(dateUtil.now()).thenReturn(now) + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(1) + val lastCalcTime = now - 30_000L + setLastBgCalcTriggeredAt(lastCalcTime) + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(lastCalcTime) + } +}