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)
+ }
+}