From d9166a4895b728a8c43a3f8aa2e09da569a2ce2e Mon Sep 17 00:00:00 2001 From: Michael Nelson Date: Tue, 28 Apr 2026 07:31:34 -0700 Subject: [PATCH 1/4] Loop throttling reimplement on v4 --- .../main/kotlin/app/aaps/core/keys/IntKey.kt | 9 ++ core/keys/src/main/res/values/strings.xml | 2 + .../app/aaps/plugins/aps/loop/LoopPlugin.kt | 3 +- .../IobCobCalculatorPlugin.kt | 15 ++ .../IobCobCalculatorPluginThrottleTest.kt | 149 ++++++++++++++++++ 5 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 plugins/main/src/test/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPluginThrottleTest.kt 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/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..d538bfb6fb82 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, + IntKey.LoopMinBgRecalcInterval ), icon = pluginDescription.icon ) 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..5f22047499b5 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,23 @@ 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) { + val intervalMinutes = preferences.get(IntKey.LoopMinBgRecalcInterval) + if (intervalMinutes > 0) { + 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 + } + lastBgCalcTriggeredAt = now + } + } // 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 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..401bb1e37a10 --- /dev/null +++ b/plugins/main/src/test/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPluginThrottleTest.kt @@ -0,0 +1,149 @@ +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 var fakeNow: Long = 0L + + 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 + ) + fakeNow = 1_700_000_000_000L + whenever(dateUtil.now()).thenAnswer { fakeNow } + } + + // --- Throttle disabled (interval = 0) --- + + @Test + fun `throttle disabled - BG-triggered event does not update lastBgCalcTriggeredAt`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(0) + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(0L) + } + + // --- Throttle enabled, BG-triggered --- + + @Test + fun `first BG-triggered event passes through and updates timestamp`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(fakeNow) + } + + @Test + fun `second BG-triggered event within interval is throttled`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + setLastBgCalcTriggeredAt(fakeNow) // just ran + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + // unchanged = early-return (throttled) before scheduling + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(fakeNow) + } + + @Test + fun `BG-triggered event after interval passes through`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + setLastBgCalcTriggeredAt(fakeNow - 4 * 60 * 1000L) // 4 min ago + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(fakeNow) + } + + @Test + fun `10s grace allows slightly early BG-triggered event`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + // 2m55s ago — within the 10s grace window of a 3-minute interval + setLastBgCalcTriggeredAt(fakeNow - (2 * 60 * 1000L + 55 * 1000L)) + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(fakeNow) + } + + // --- Non-BG events bypass throttle --- + + @Test + fun `non-BG event bypasses throttle even within interval`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + val oldStamp = fakeNow - 1000L + setLastBgCalcTriggeredAt(oldStamp) // just ran (BG-wise) + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = false, triggeredByNewBG = false) + + // throttle didn't fire (triggeredByNewBG=false), so lastBgCalcTriggeredAt unchanged + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(oldStamp) + } + + // --- Edge cases --- + + @Test + fun `1 minute interval blocks rapid BG-triggered events`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(1) + val oldStamp = fakeNow - 30_000L // 30s ago + setLastBgCalcTriggeredAt(oldStamp) + + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) + + // 30s < 50s (1 min - 10s grace), so throttled + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(oldStamp) + } +} From 71365aa541aec50a6349e2825e24921dd0093bb7 Mon Sep 17 00:00:00 2001 From: Michael Nelson Date: Tue, 28 Apr 2026 07:31:56 -0700 Subject: [PATCH 2/4] Add CustomPreferenceItem + expand to include loop count --- .../preference/AdaptivePreferenceList.kt | 3 + .../preference/CustomPreferenceItem.kt | 16 +++ .../preference/PreferenceContentExtensions.kt | 2 + .../aps/loop/LoopIntervalPreferenceItem.kt | 112 ++++++++++++++++++ .../app/aaps/plugins/aps/loop/LoopPlugin.kt | 2 +- plugins/aps/src/main/res/values/strings.xml | 5 + 6 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/CustomPreferenceItem.kt create mode 100644 plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopIntervalPreferenceItem.kt 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..d7d6aa5a8bc0 --- /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. + */ +abstract class CustomPreferenceItem : PreferenceItem { + + @Composable + abstract 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..a5c2a0351afb --- /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 d538bfb6fb82..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 @@ -1012,7 +1012,7 @@ class LoopPlugin @Inject constructor( titleResId = app.aaps.core.ui.R.string.loop, items = listOf( IntKey.LoopOpenModeMinChange, - IntKey.LoopMinBgRecalcInterval + 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 From 8e59508d9b2addf3ed987d47d9ea3d886f6eb225 Mon Sep 17 00:00:00 2001 From: Michael Nelson Date: Tue, 28 Apr 2026 21:21:27 -0700 Subject: [PATCH 3/4] Testfile cleanup --- .../IobCobCalculatorPluginThrottleTest.kt | 79 ++++++++++--------- 1 file changed, 41 insertions(+), 38 deletions(-) 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 index 401bb1e37a10..78245637aeb4 100644 --- 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 @@ -39,7 +39,6 @@ class IobCobCalculatorPluginThrottleTest : TestBase() { @Mock lateinit var dateUtil: DateUtil private lateinit var sut: IobCobCalculatorPlugin - private var fakeNow: Long = 0L private fun getLastBgCalcTriggeredAt(): Long { val field = IobCobCalculatorPlugin::class.java.getDeclaredField("lastBgCalcTriggeredAt") @@ -61,89 +60,93 @@ class IobCobCalculatorPluginThrottleTest : TestBase() { persistenceLayer, overviewData, calculationWorkflow, decimalFormatter, processedTbrEbData, signals, cache ) - fakeNow = 1_700_000_000_000L - whenever(dateUtil.now()).thenAnswer { fakeNow } } - // --- Throttle disabled (interval = 0) --- - @Test - fun `throttle disabled - BG-triggered event does not update lastBgCalcTriggeredAt`() { + 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(0L) + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(priorTimestamp) } - // --- Throttle enabled, BG-triggered --- - @Test - fun `first BG-triggered event passes through and updates timestamp`() { + 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 = true, triggeredByNewBG = true) + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = false, triggeredByNewBG = false) - assertThat(getLastBgCalcTriggeredAt()).isEqualTo(fakeNow) + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(lastCalcTime) } @Test - fun `second BG-triggered event within interval is throttled`() { + 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) - setLastBgCalcTriggeredAt(fakeNow) // just ran + val priorTimestamp = 0L sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) - // unchanged = early-return (throttled) before scheduling - assertThat(getLastBgCalcTriggeredAt()).isEqualTo(fakeNow) + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(now) } @Test - fun `BG-triggered event after interval passes through`() { + 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(fakeNow - 4 * 60 * 1000L) // 4 min ago + setLastBgCalcTriggeredAt(now - 4 * 60 * 1000L) sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) - assertThat(getLastBgCalcTriggeredAt()).isEqualTo(fakeNow) + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(now) } @Test - fun `10s grace allows slightly early BG-triggered event`() { + 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) - // 2m55s ago — within the 10s grace window of a 3-minute interval - setLastBgCalcTriggeredAt(fakeNow - (2 * 60 * 1000L + 55 * 1000L)) + setLastBgCalcTriggeredAt(now - (2 * 60 * 1000L + 55 * 1000L)) sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) - assertThat(getLastBgCalcTriggeredAt()).isEqualTo(fakeNow) + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(now) } - // --- Non-BG events bypass throttle --- - @Test - fun `non-BG event bypasses throttle even within interval`() { + 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 oldStamp = fakeNow - 1000L - setLastBgCalcTriggeredAt(oldStamp) // just ran (BG-wise) + val priorTimestamp = now + setLastBgCalcTriggeredAt(priorTimestamp) - sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = false, triggeredByNewBG = false) + sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) - // throttle didn't fire (triggeredByNewBG=false), so lastBgCalcTriggeredAt unchanged - assertThat(getLastBgCalcTriggeredAt()).isEqualTo(oldStamp) + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(priorTimestamp) } - // --- Edge cases --- - @Test - fun `1 minute interval blocks rapid BG-triggered events`() { + 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 oldStamp = fakeNow - 30_000L // 30s ago - setLastBgCalcTriggeredAt(oldStamp) + val lastCalcTime = now - 30_000L + setLastBgCalcTriggeredAt(lastCalcTime) sut.scheduleHistoryDataChange(oldDataTimestamp = 1000L, reloadBgData = true, triggeredByNewBG = true) - // 30s < 50s (1 min - 10s grace), so throttled - assertThat(getLastBgCalcTriggeredAt()).isEqualTo(oldStamp) + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(lastCalcTime) } } From 1926c6acd355bb38282c9b46111e8980c4da7b8f Mon Sep 17 00:00:00 2001 From: Michael Nelson Date: Wed, 29 Apr 2026 20:02:43 -0700 Subject: [PATCH 4/4] Break up function, convert to interface --- .../preference/CustomPreferenceItem.kt | 4 +-- .../aps/loop/LoopIntervalPreferenceItem.kt | 2 +- .../IobCobCalculatorPlugin.kt | 28 ++++++++++--------- 3 files changed, 18 insertions(+), 16 deletions(-) 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 index d7d6aa5a8bc0..9191e3803b55 100644 --- 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 @@ -9,8 +9,8 @@ import app.aaps.core.keys.interfaces.PreferenceItem * data alongside an editable value. Subclass and override [Content] to render a row * that the framework will place inline among regular preferences. */ -abstract class CustomPreferenceItem : PreferenceItem { +interface CustomPreferenceItem : PreferenceItem { @Composable - abstract fun Content() + fun Content() } 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 index a5c2a0351afb..13377a2f6f4a 100644 --- 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 @@ -37,7 +37,7 @@ import java.text.DecimalFormat */ class LoopIntervalPreferenceItem( private val persistenceLayer: PersistenceLayer -) : CustomPreferenceItem() { +) : CustomPreferenceItem { @Composable override fun Content() { 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 5f22047499b5..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 @@ -451,19 +451,7 @@ class IobCobCalculatorPlugin @Inject constructor( @Synchronized fun scheduleHistoryDataChange(oldDataTimestamp: Long, reloadBgData: Boolean, triggeredByNewBG: Boolean = false) { - if (triggeredByNewBG) { - val intervalMinutes = preferences.get(IntKey.LoopMinBgRecalcInterval) - if (intervalMinutes > 0) { - 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 - } - lastBgCalcTriggeredAt = now - } - } + 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 @@ -493,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")