Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions core/keys/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@
<!-- IntKey: Loop preferences -->
<string name="pref_title_open_mode_min_change">Open mode minimum change</string>
<string name="loop_open_mode_min_change_summary" formatted="false">Open Loop will popup new change request only if change is bigger than this value in %. Default value is 20%</string>
<string name="pref_title_loop_min_bg_recalc_interval">Loop frequency limit</string>
<string name="loop_min_bg_recalc_interval_summary">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.</string>

<!-- IntKey: APS preferences -->
<string name="pref_title_smb_frequency">SMB interval</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ private fun RenderPreferenceItems(
}
}

is CustomPreferenceItem -> item.Content()

is PreferenceSubScreenDef -> {
val shouldShow = shouldShowSubScreenInline(
subScreen = item,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int?>(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)
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
5 changes: 5 additions & 0 deletions plugins/aps/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@
<string name="loop_smb_request_time_label">SMB request time</string>
<string name="loop_smb_execution_time_label">SMB execution time</string>
<string name="loop_smb_set_by_pump_label">SMB set by pump</string>
<string name="loop_recalc_every_sensor_reading">Recalculates every time a sensor reading arrives</string>
<string name="loop_recalc_loops_per_day" formatted="false">~%1$d loops/day</string>
<string name="loop_recalc_daily_count" formatted="false">Actual usage: %1$d loops in the last 24h</string>
<string name="loop_recalc_daily_count_warning" formatted="false">Actual usage: %1$d loops in the last 24h. This may excessively drain battery; a higher frequency limit is recommended.</string>
<string name="loop_recalc_high_rate_warning">May noticeably reduce battery life.</string>
<string name="fallback_smb_no_tdd">Fallback to SMB. Not enough TDD data.</string>
<string name="fallback_to_isf_no_tdd">Fallback to profile sensitivity. Not enough data. Reason: %1$s</string>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OverviewDataCache>
@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)
}
}