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 ebe8ba84713..9e60e5850bf 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 @@ -48,6 +48,7 @@ enum class IntKey( ProtectionTypeBolus("bolus_protection", 0, 0, 5), SafetyMaxCarbs("treatmentssafety_maxcarbs", 48, 1, 200), LoopOpenModeMinChange("loop_openmode_min_change", 30, 0, 50, defaultedBySM = true), + LoopMinBgRecalcInterval("loop_min_bg_recalc_interval", 0, 0, 30), ApsMaxSmbFrequency("smbinterval", 3, 1, 10, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb), ApsMaxMinutesOfBasalToLimitSmb("smbmaxminutes", 30, 15, 120, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb), ApsUamMaxMinutesOfBasalToLimitSmb("uamsmbmaxminutes", 30, 15, 120, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb), diff --git a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/di/LoopModule.kt b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/di/LoopModule.kt index 6cce0fc99bd..c92d672c435 100644 --- a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/di/LoopModule.kt +++ b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/di/LoopModule.kt @@ -2,6 +2,7 @@ package app.aaps.plugins.aps.di import app.aaps.plugins.aps.loop.CarbSuggestionReceiver import app.aaps.plugins.aps.loop.LoopFragment +import app.aaps.plugins.aps.loop.LoopIntervalPreference import dagger.Module import dagger.android.ContributesAndroidInjector @@ -10,4 +11,5 @@ import dagger.android.ContributesAndroidInjector abstract class LoopModule { @ContributesAndroidInjector abstract fun contributesLoopFragment(): LoopFragment @ContributesAndroidInjector abstract fun contributesCarbSuggestionReceiver(): CarbSuggestionReceiver + @ContributesAndroidInjector abstract fun contributesLoopIntervalPreference(): LoopIntervalPreference } \ No newline at end of file diff --git a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopIntervalPreference.kt b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopIntervalPreference.kt new file mode 100644 index 00000000000..944c2ccea82 --- /dev/null +++ b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopIntervalPreference.kt @@ -0,0 +1,97 @@ +package app.aaps.plugins.aps.loop + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.TextView +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.data.time.T +import app.aaps.core.keys.IntKey +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.plugins.aps.R +import com.google.android.material.button.MaterialButtonToggleGroup +import dagger.android.HasAndroidInjector +import javax.inject.Inject + +class LoopIntervalPreference( + ctx: Context, + attrs: AttributeSet? = null +) : Preference(ctx, attrs) { + + @Inject lateinit var preferences: Preferences + @Inject lateinit var rh: ResourceHelper + @Inject lateinit var dateUtil: DateUtil + @Inject lateinit var persistenceLayer: PersistenceLayer + + private val valueToButton = mapOf( + 0 to R.id.btn_auto, + 1 to R.id.btn_1, + 2 to R.id.btn_2, + 3 to R.id.btn_3, + 4 to R.id.btn_4, + 5 to R.id.btn_5 + ) + private val buttonToValue = valueToButton.entries.associate { it.value to it.key } + + init { + (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) + layoutResource = R.layout.preference_loop_interval + key = IntKey.LoopMinBgRecalcInterval.key + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + holder.isDividerAllowedAbove = false + holder.isDividerAllowedBelow = false + // Disable click on the row background so taps reach the toggle buttons instead + holder.itemView.isClickable = false + + val titleView = holder.findViewById(R.id.title) as? TextView + val dynamicSummary = holder.findViewById(R.id.dynamic_summary) as? TextView + val warningText = holder.findViewById(R.id.warning_text) as? TextView + val helpText = holder.findViewById(R.id.help_text) as? TextView + val toggleGroup = holder.findViewById(R.id.toggle_group) as? MaterialButtonToggleGroup + + titleView?.text = rh.gs(R.string.loop_min_bg_recalc_interval_title) + helpText?.text = rh.gs(R.string.loop_min_bg_recalc_interval_summary) + + val currentValue = preferences.get(IntKey.LoopMinBgRecalcInterval) + updateDynamicText(dynamicSummary, warningText, currentValue) + + toggleGroup?.clearOnButtonCheckedListeners() + valueToButton[currentValue]?.let { toggleGroup?.check(it) } + + toggleGroup?.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (!isChecked) return@addOnButtonCheckedListener + val minutes = buttonToValue[checkedId] ?: return@addOnButtonCheckedListener + preferences.put(IntKey.LoopMinBgRecalcInterval, minutes) + updateDynamicText(dynamicSummary, warningText, minutes) + } + } + + private fun updateDynamicText(summaryView: TextView?, warningText: TextView?, minutes: Int) { + val now = dateUtil.now() + val dailyCount = persistenceLayer.getApsResults(now - T.hours(24).msecs(), now).size + val loopsPerDay = if (minutes == 0) dailyCount else 1440 / minutes + + // 1st line: always white, describes selected mode + summaryView?.text = if (minutes == 0) rh.gs(R.string.loop_recalc_every_sensor_reading) + else rh.gs(R.string.loop_recalc_loops_per_day, loopsPerDay) + + // 2nd line: red warning, shown if actual or projected high usage + if (dailyCount > 500) { // actual high usage + warningText?.text = rh.gs(R.string.loop_recalc_daily_count_warning, dailyCount) + warningText?.setTextColor(rh.gc(app.aaps.core.ui.R.color.warning)) + } else if (minutes in 1..2) { // low data but high-rate selection + warningText?.text = rh.gs(R.string.loop_recalc_high_rate_warning) + warningText?.setTextColor(rh.gc(app.aaps.core.ui.R.color.warning)) + } else { + warningText?.text = rh.gs(R.string.loop_recalc_daily_count, dailyCount) + warningText?.setTextColor(rh.gc(android.R.color.white)) + } + } +} 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 23188313d23..50f6e853323 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 @@ -1046,6 +1046,7 @@ class LoopPlugin @Inject constructor( title = rh.gs(app.aaps.core.ui.R.string.loop) initialExpandedChildrenCount = 0 addPreference(AdaptiveIntPreference(ctx = context, intKey = IntKey.LoopOpenModeMinChange, dialogMessage = R.string.loop_open_mode_min_change_summary, title = R.string.loop_open_mode_min_change)) + addPreference(LoopIntervalPreference(context)) } } diff --git a/plugins/aps/src/main/res/layout/preference_loop_interval.xml b/plugins/aps/src/main/res/layout/preference_loop_interval.xml new file mode 100644 index 00000000000..cc8cf0dfc44 --- /dev/null +++ b/plugins/aps/src/main/res/layout/preference_loop_interval.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/aps/src/main/res/values/strings.xml b/plugins/aps/src/main/res/values/strings.xml index 30e1e85f956..c6ebebabab0 100644 --- a/plugins/aps/src/main/res/values/strings.xml +++ b/plugins/aps/src/main/res/values/strings.xml @@ -128,6 +128,13 @@ SMB set by pump Minimal request change [%] Open Loop will popup new change request only if change is bigger than this value in %. Default value is 20% + Loop frequency limit, mins + Sets the minimum time between full loop iterations. 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. 3 minutes or higher is recommended for most users. + 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. + Recalculates every time a sensor reading arrives + ~%1$d loops/day 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 f4b6354fa8a..3351bc3b7cd 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 @@ -419,8 +419,23 @@ class IobCobCalculatorPlugin @Inject constructor( private var scheduledHistoryPost: ScheduledFuture<*>? = null private var scheduledEvent: EventNewHistoryData? = null + private var lastBgCalcTriggeredAt: Long = 0L + @Synchronized private fun scheduleHistoryDataChange(event: EventNewHistoryData) { + val intervalMinutes = preferences.get(IntKey.LoopMinBgRecalcInterval) + if (intervalMinutes > 0 && event.reloadBgData && event.newestGlucoseValueTimestamp != null) { + val now = System.currentTimeMillis() + val intervalMs = intervalMinutes * 60 * 1000L - 10_000L + val timeSinceLastCalc = now - lastBgCalcTriggeredAt + + if (timeSinceLastCalc < intervalMs) { + aapsLogger.debug(LTag.APS, "Throttled BG recalculation: ${timeSinceLastCalc / 1000}s since last, interval=${intervalMs / 1000}s") + return + } + lastBgCalcTriggeredAt = now + } + // if there is nothing scheduled or asking reload deeper to the past if (scheduledEvent == null || event.oldDataTimestamp < (scheduledEvent?.oldDataTimestamp ?: 0L)) { // cancel waiting task to prevent sending multiple posts @@ -672,4 +687,4 @@ class IobCobCalculatorPlugin @Inject constructor( } return total } -} \ No newline at end of file +} 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 00000000000..0283eb0ecce --- /dev/null +++ b/plugins/main/src/test/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPluginThrottleTest.kt @@ -0,0 +1,184 @@ +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.plugin.ActivePlugin +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.rx.events.EventNewHistoryData +import app.aaps.core.interfaces.utils.DecimalFormatter +import app.aaps.core.interfaces.utils.fabric.FabricPrivacy +import app.aaps.core.interfaces.workflow.CalculationWorkflow +import app.aaps.core.keys.IntKey +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.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.keys.interfaces.Preferences + +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 dateUtil: app.aaps.core.interfaces.utils.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) + } + + private fun callScheduleHistoryDataChange(event: EventNewHistoryData) { + val method = IobCobCalculatorPlugin::class.java.getDeclaredMethod("scheduleHistoryDataChange", EventNewHistoryData::class.java) + method.isAccessible = true + method.invoke(sut, event) + } + + @BeforeEach + fun setup() { + sut = IobCobCalculatorPlugin( + aapsLogger, aapsSchedulers, rxBus, preferences, rh, + profileFunction, activePlugin, fabricPrivacy, dateUtil, + persistenceLayer, overviewData, calculationWorkflow, + decimalFormatter, processedTbrEbData + ) + } + + // --- Throttle disabled (interval = 0) --- + + @Test + fun `throttle disabled - BG event always passes through`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(0) + val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L) + + callScheduleHistoryDataChange(event) + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(0L) // not updated when throttle disabled + } + + @Test + fun `throttle disabled - rapid BG events all pass through`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(0) + + repeat(5) { + val event = EventNewHistoryData(oldDataTimestamp = 1000L + it, reloadBgData = true, newestGlucoseValueTimestamp = 2000L + it) + callScheduleHistoryDataChange(event) + } + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(0L) + } + + // --- Throttle enabled --- + + @Test + fun `throttle enabled - first BG event passes through and updates timestamp`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + + val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L) + callScheduleHistoryDataChange(event) + + assertThat(getLastBgCalcTriggeredAt()).isGreaterThan(0L) + } + + @Test + fun `throttle enabled - second BG event within interval is throttled`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + setLastBgCalcTriggeredAt(System.currentTimeMillis()) // just ran + + val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L) + val before = getLastBgCalcTriggeredAt() + callScheduleHistoryDataChange(event) + + // timestamp unchanged = event was throttled (early return before scheduling) + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(before) + } + + @Test + fun `throttle enabled - BG event after interval passes through`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + // 4 minutes ago — beyond the 3-minute interval + setLastBgCalcTriggeredAt(System.currentTimeMillis() - 4 * 60 * 1000L) + + val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L) + callScheduleHistoryDataChange(event) + + // timestamp updated = event passed through + assertThat(getLastBgCalcTriggeredAt()).isGreaterThan(System.currentTimeMillis() - 1000L) + } + + @Test + fun `throttle enabled - 10s grace allows slightly early event`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + // 2 min 55 sec ago — within the 10s grace window of 3-minute interval + setLastBgCalcTriggeredAt(System.currentTimeMillis() - (2 * 60 * 1000L + 55 * 1000L)) + + val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L) + callScheduleHistoryDataChange(event) + + // should pass through due to grace period (interval is 3min - 10s = 2min50s) + assertThat(getLastBgCalcTriggeredAt()).isGreaterThan(System.currentTimeMillis() - 1000L) + } + + // --- Non-BG events bypass throttle --- + + @Test + fun `non-BG event bypasses throttle even within interval`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + setLastBgCalcTriggeredAt(System.currentTimeMillis()) // just ran + + // reloadBgData = false means this is a treatment/profile change, not a BG event + val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = false, newestGlucoseValueTimestamp = null) + val before = getLastBgCalcTriggeredAt() + callScheduleHistoryDataChange(event) + + // timestamp unchanged but event was NOT throttled (condition doesn't match) + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(before) + } + + @Test + fun `BG event without newestGlucoseValueTimestamp bypasses throttle`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3) + setLastBgCalcTriggeredAt(System.currentTimeMillis()) // just ran + + val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = null) + val before = getLastBgCalcTriggeredAt() + callScheduleHistoryDataChange(event) + + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(before) + } + + // --- Edge cases --- + + @Test + fun `throttle with 1 minute interval blocks rapid events`() { + whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(1) + setLastBgCalcTriggeredAt(System.currentTimeMillis() - 30_000L) // 30s ago + + val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L) + val before = getLastBgCalcTriggeredAt() + callScheduleHistoryDataChange(event) + + // 30s < 50s (1min - 10s grace), so throttled + assertThat(getLastBgCalcTriggeredAt()).isEqualTo(before) + } +}