Skip to content

Commit 6a0a789

Browse files
authored
Resolve reported native basal segments with explicit gaps (#25)
Co-authored-by: siriushex <siriushex@users.noreply.github.com>
1 parent 4f1d173 commit 6a0a789

15 files changed

Lines changed: 577 additions & 9 deletions

‎AI_NOTES.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
# Native Reported Basal Segments - 2026-10-23
2+
3+
- Continue from PR24/main4f1d173d. All six source checks passed and merged/tested
4+
trees match. Automatic main CI remains a separate observation.
5+
- Initial16inventory tests reproduced two failures: contradictory absolute/start
6+
rates and ignored unknown TBR type. Follow-up19segment tests reproduced duplicate
7+
pump identity admission and timestamp-bearing gap rendering. New source APIs
8+
failed compilation before wiring. These original failing logs are retained.
9+
- Preserve native TBR type and converted start rate. Join bounded reported spans
10+
to historical EPS at one retained revision; clip/split without filling gaps or
11+
inventing preemption. Absolute, percentage and extended-emulation addition stay
12+
distinct; extra component units are not total basal or confirmed delivery.
13+
- Native ProfileSealed/MidnightUtils use system-local schedule time. The inspected
14+
helper sources match pinned RC1, so a serialized profile timezone is not treated
15+
as historical device-clock evidence. Percentage numeric units remain unavailable.
16+
- Add immutable/redacted span/gap diagnostics to the existing connection owner.
17+
Corrections, deletion/reopen and actual pristine SDK TLS passed73focused checks.
18+
Frozen full quality passed in7m20s:6790passed/six existing skips, zero failures/
19+
errors, actual SDK TLS2.777s and lint zero errors/382existing warnings/eight hints.
20+
All1431source hashes stayed unchanged; all7176compiled app classes were retained
21+
by the APK guard. Exact-head CI and main integration remain separate gates.
22+
No worker/schema, AAPS patch, forecast/dose/target/alarm,
23+
live recommended meal time or APK/device/server deployment is introduced.
24+
125
# Native Historical Profile Windows - 2026-10-23
226

327
- Continue from verified PR23/main a63e0f3f. All six source checks passed and

‎android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3EffectiveProfileSource.kt‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ internal data class AapsV3EffectiveProfileRead(
2121
}
2222

2323
internal data class AapsV3ObservedClinicalRead(val profile: AapsV3EffectiveProfileRead, val insulin: AapsV3InsulinRead,
24-
val profileHistory: AapsV3ProfileHistoryRead) {
24+
val profileHistory: AapsV3ProfileHistoryRead, val reportedBasal: AapsV3ReportedBasalRead) {
2525
override fun toString() = "AapsV3ObservedClinicalRead(redacted)"
2626
}
2727

@@ -45,7 +45,7 @@ internal class AapsV3EffectiveProfileSource(
4545
currentCoroutineContext().ensureActive()
4646
fun unavailable(profile: AapsV3ObservedProfileStatus, insulin: AapsV3ObservedInsulinStatus) =
4747
AapsV3ObservedClinicalRead(AapsV3EffectiveProfileRead(profile, null), AapsV3InsulinRead(insulin, null),
48-
AapsV3ProfileHistoryRead(profile, null))
48+
AapsV3ProfileHistoryRead(profile, null), AapsV3ReportedBasalRead(AapsV3ReportedBasalStatus.INPUT_UNAVAILABLE, null))
4949
if (fromMs !in 1..atMs || atMs > knownAtMs || knownAtMs > LocalNightscoutV3Store.MAX_TIME) {
5050
return@withLock unavailable(AapsV3ObservedProfileStatus.INVALID, AapsV3ObservedInsulinStatus.INVALID)
5151
}
@@ -55,8 +55,10 @@ internal class AapsV3EffectiveProfileSource(
5555
store.retainedSnapshot(LocalV3Collection.TREATMENTS, maxRows, maxPayloadBytes)
5656
).also { cached = it }
5757
currentCoroutineContext().ensureActive()
58-
AapsV3ObservedClinicalRead(index.profile.read(atMs, knownAtMs), index.insulin.read(fromMs, atMs, knownAtMs),
59-
index.profile.readWindow(fromMs, atMs, knownAtMs))
58+
val insulin = index.insulin.read(fromMs, atMs, knownAtMs)
59+
val history = index.profile.readWindow(fromMs, atMs, knownAtMs)
60+
AapsV3ObservedClinicalRead(index.profile.read(atMs, knownAtMs), insulin, history,
61+
AapsV3ReportedBasalProjector.read(fromMs, atMs, knownAtMs, history, insulin))
6062
} catch (cancelled: CancellationException) { throw cancelled }
6163
catch (_: Exception) {
6264
cached = null

‎android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3InsulinInventory.kt‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import kotlinx.serialization.json.*
1010

1111
enum class AapsV3ObservedInsulinStatus { UNCHECKED, OBSERVED, PARTIAL_CONTEXT, NOT_OBSERVED, INVALID, CONFLICTING, LATE_EVIDENCE, LIMIT_REACHED, ERROR }
1212

13+
internal enum class AapsV3TempBasalType { NORMAL, EMULATED_PUMP_SUSPEND, PUMP_SUSPEND, SUPERBOLUS, FAKE_EXTENDED }
14+
1315
internal sealed class AapsV3InsulinDose {
1416
data class Bolus(val units: Double, val type: String, val basalInsulin: Boolean,
1517
val configuration: AapsV3InsulinConfiguration?) : AapsV3InsulinDose()
16-
data class TempBasal(val durationMs: Long, val absoluteUnitsPerHour: Double?, val percentAdjustment: Double?) : AapsV3InsulinDose()
18+
data class TempBasal(val durationMs: Long, val absoluteUnitsPerHour: Double?, val percentAdjustment: Double?,
19+
val type: AapsV3TempBasalType = AapsV3TempBasalType.NORMAL, val reportedRateAtStart: Double? = null) : AapsV3InsulinDose()
1720
data class Extended(val units: Double, val durationMs: Long, val emulatingTempBasal: Boolean) : AapsV3InsulinDose()
1821
final override fun toString() = "AapsV3InsulinDose(redacted)"
1922
}
@@ -125,7 +128,10 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot)
125128
val absolute = number(body, "absolute")?.also { require(it >= 0) }
126129
val percent = number(body, "percent")?.also { require(it >= -100) }
127130
require((absolute != null) xor (percent != null))
128-
AapsV3InsulinDose.TempBasal(duration, absolute, percent)
131+
val type = AapsV3TempBasalType.valueOf(string(body, "type") ?: "NORMAL")
132+
val startRate = number(body, "rate")?.also { require(it >= 0) }
133+
require(absolute == null || startRate == null || absolute == startRate)
134+
AapsV3InsulinDose.TempBasal(duration, absolute, percent, type, startRate)
129135
}
130136
"Combo Bolus" -> extended(body)
131137
"Correction Bolus", "Meal Bolus", "Snack Bolus" -> throw IllegalArgumentException("missing_native_insulin")
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package io.aaps.copilot.service
2+
3+
import io.aaps.copilot.data.repository.LocalNightscoutV3Store
4+
import java.util.Collections
5+
6+
enum class AapsV3ReportedBasalStatus {
7+
UNCHECKED, OBSERVED_WINDOW, OBSERVED_WITH_GAPS, NOT_OBSERVED, PROFILE_UNAVAILABLE,
8+
INPUT_UNAVAILABLE, REVISION_MISMATCH, INVALID, LATE_EVIDENCE, CONFLICTING, UNSUPPORTED, LIMIT_REACHED
9+
}
10+
11+
internal sealed class AapsV3BasalRate {
12+
data class Absolute(val unitsPerHour: Double) : AapsV3BasalRate()
13+
data class PercentAdjustment(val percent: Double) : AapsV3BasalRate()
14+
data class ExtendedAddition(val unitsPerHour: Double) : AapsV3BasalRate()
15+
final override fun toString() = "AapsV3BasalRate(redacted)"
16+
}
17+
18+
internal data class AapsV3ReportedBasalSegment(
19+
val startMs: Long, val endMs: Long, val rate: AapsV3BasalRate,
20+
val source: AapsV3InsulinObservation, val profile: AapsV3EffectiveProfileObservation
21+
) {
22+
/** A reported component, never independently confirmed delivery or total emulated basal. */
23+
val reportedComponentUnits: Double? get() = when (rate) {
24+
is AapsV3BasalRate.Absolute -> rate.unitsPerHour * ((endMs - startMs) / 3_600_000.0)
25+
is AapsV3BasalRate.ExtendedAddition -> rate.unitsPerHour * ((endMs - startMs) / 3_600_000.0)
26+
is AapsV3BasalRate.PercentAdjustment -> null
27+
}
28+
override fun toString() = "AapsV3ReportedBasalSegment(redacted)"
29+
}
30+
31+
internal data class AapsV3BasalGap(val startMs: Long, val endMs: Long) {
32+
override fun toString() = "AapsV3BasalGap(redacted)"
33+
}
34+
35+
internal class AapsV3ReportedBasalRead(
36+
val status: AapsV3ReportedBasalStatus,
37+
val retainedRevision: Long?,
38+
segments: List<AapsV3ReportedBasalSegment> = emptyList(),
39+
gaps: List<AapsV3BasalGap> = emptyList()
40+
) {
41+
val segments: List<AapsV3ReportedBasalSegment> = Collections.unmodifiableList(segments.toList())
42+
val gaps: List<AapsV3BasalGap> = Collections.unmodifiableList(gaps.toList())
43+
val upstreamCompletenessVerified: Boolean get() = false
44+
override fun toString() = "AapsV3ReportedBasalRead(status=$status, redacted)"
45+
}
46+
47+
/** Reported intervals only. Missing records are not normal basal and profile timezone is not a clock receipt. */
48+
internal object AapsV3ReportedBasalProjector {
49+
private data class Span(val startMs: Long, val endMs: Long, val rate: AapsV3BasalRate, val source: AapsV3InsulinObservation)
50+
51+
fun read(fromMs: Long, untilMs: Long, knownAtMs: Long, profiles: AapsV3ProfileHistoryRead,
52+
insulin: AapsV3InsulinRead): AapsV3ReportedBasalRead {
53+
val revision = profiles.retainedRevision
54+
fun unavailable(status: AapsV3ReportedBasalStatus) = AapsV3ReportedBasalRead(status, revision)
55+
if (fromMs !in 1 until untilMs || untilMs > knownAtMs || knownAtMs > LocalNightscoutV3Store.MAX_TIME)
56+
return unavailable(AapsV3ReportedBasalStatus.INVALID)
57+
if (untilMs - fromMs > 172_800_000L) return unavailable(AapsV3ReportedBasalStatus.LIMIT_REACHED)
58+
if (profiles.status != AapsV3ObservedProfileStatus.OBSERVED)
59+
return unavailable(AapsV3ReportedBasalStatus.PROFILE_UNAVAILABLE)
60+
if (insulin.status !in setOf(AapsV3ObservedInsulinStatus.OBSERVED,
61+
AapsV3ObservedInsulinStatus.PARTIAL_CONTEXT, AapsV3ObservedInsulinStatus.NOT_OBSERVED))
62+
return unavailable(AapsV3ReportedBasalStatus.INPUT_UNAVAILABLE)
63+
if (revision == null || revision != insulin.retainedRevision)
64+
return unavailable(AapsV3ReportedBasalStatus.REVISION_MISMATCH)
65+
if (revision > knownAtMs || insulin.records.any { it.availableAtMs > knownAtMs })
66+
return unavailable(AapsV3ReportedBasalStatus.LATE_EVIDENCE)
67+
if (profiles.intervals.size > 2880 || insulin.records.size > 10_000)
68+
return unavailable(AapsV3ReportedBasalStatus.LIMIT_REACHED)
69+
val history = profiles.intervals
70+
if (history.isEmpty() || history.first().startMs != fromMs || history.last().endMs != untilMs ||
71+
history.zipWithNext().any { (a, b) -> a.endMs != b.startMs } || history.any {
72+
it.startMs >= it.endMs || it.observation.eventAtMs > it.startMs ||
73+
it.observation.createdRevision !in 1..it.observation.modifiedRevision ||
74+
it.observation.modifiedRevision > revision
75+
} || insulin.records.any { it.identifier.isBlank() || it.createdRevision !in 1..it.modifiedRevision ||
76+
it.modifiedRevision > revision || it.eventAtMs !in 1..LocalNightscoutV3Store.MAX_TIME })
77+
return unavailable(AapsV3ReportedBasalStatus.INVALID)
78+
if (insulin.records.map { it.identifier }.distinct().size != insulin.records.size)
79+
return unavailable(AapsV3ReportedBasalStatus.CONFLICTING)
80+
val pumpKeys = insulin.records.mapNotNull { it.pumpRecordIdentity }
81+
if (pumpKeys.distinct().size != pumpKeys.size) return unavailable(AapsV3ReportedBasalStatus.CONFLICTING)
82+
val spans = mutableListOf<Span>()
83+
for (record in insulin.records) {
84+
if (record.invalidated) continue
85+
val duration: Long
86+
val rate: AapsV3BasalRate
87+
when (val dose = record.dose) {
88+
is AapsV3InsulinDose.Bolus -> {
89+
if (dose.basalInsulin) return unavailable(AapsV3ReportedBasalStatus.UNSUPPORTED)
90+
continue
91+
}
92+
is AapsV3InsulinDose.TempBasal -> {
93+
if (dose.type == AapsV3TempBasalType.FAKE_EXTENDED) return unavailable(AapsV3ReportedBasalStatus.UNSUPPORTED)
94+
duration = dose.durationMs
95+
val absolute = dose.absoluteUnitsPerHour
96+
val percent = dose.percentAdjustment
97+
if ((absolute != null) == (percent != null) || absolute?.let { !it.isFinite() || it < 0 } == true ||
98+
percent?.let { !it.isFinite() || it < -100 } == true)
99+
return unavailable(AapsV3ReportedBasalStatus.INVALID)
100+
rate = absolute?.let(AapsV3BasalRate::Absolute) ?: AapsV3BasalRate.PercentAdjustment(checkNotNull(percent))
101+
}
102+
is AapsV3InsulinDose.Extended -> {
103+
if (!dose.emulatingTempBasal) continue
104+
if (dose.durationMs == 0L && dose.units != 0.0) return unavailable(AapsV3ReportedBasalStatus.UNSUPPORTED)
105+
if (!dose.units.isFinite() || dose.units < 0) return unavailable(AapsV3ReportedBasalStatus.INVALID)
106+
duration = dose.durationMs
107+
if (duration == 0L) continue
108+
val extra = dose.units / (duration / 3_600_000.0)
109+
if (!extra.isFinite()) return unavailable(AapsV3ReportedBasalStatus.INVALID)
110+
rate = AapsV3BasalRate.ExtendedAddition(extra)
111+
}
112+
}
113+
if (duration < 0 || record.eventAtMs > LocalNightscoutV3Store.MAX_TIME - duration)
114+
return unavailable(AapsV3ReportedBasalStatus.INVALID)
115+
val start = maxOf(fromMs, record.eventAtMs)
116+
val end = minOf(untilMs, record.eventAtMs + duration)
117+
if (start < end) spans += Span(start, end, rate, record)
118+
}
119+
spans.sortBy { it.startMs }
120+
if (spans.zipWithNext().any { (a, b) -> a.endMs > b.startMs })
121+
return unavailable(AapsV3ReportedBasalStatus.CONFLICTING)
122+
val segments = mutableListOf<AapsV3ReportedBasalSegment>()
123+
var profileIndex = 0
124+
for (span in spans) {
125+
var start = span.startMs
126+
while (start < span.endMs) {
127+
while (history[profileIndex].endMs <= start) profileIndex++
128+
val profile = history[profileIndex]
129+
val end = minOf(profile.endMs, span.endMs)
130+
val segment = AapsV3ReportedBasalSegment(start, end, span.rate, span.source, profile.observation)
131+
if (segment.reportedComponentUnits?.isFinite() == false)
132+
return unavailable(AapsV3ReportedBasalStatus.INVALID)
133+
segments += segment
134+
if (segments.size > 2880) return unavailable(AapsV3ReportedBasalStatus.LIMIT_REACHED)
135+
start = end
136+
}
137+
}
138+
val gaps = mutableListOf<AapsV3BasalGap>()
139+
var end = fromMs
140+
for (segment in segments) {
141+
if (end < segment.startMs) gaps += AapsV3BasalGap(end, segment.startMs)
142+
end = segment.endMs
143+
}
144+
if (end < untilMs) gaps += AapsV3BasalGap(end, untilMs)
145+
val status = when {
146+
segments.isEmpty() -> AapsV3ReportedBasalStatus.NOT_OBSERVED
147+
gaps.isEmpty() -> AapsV3ReportedBasalStatus.OBSERVED_WINDOW
148+
else -> AapsV3ReportedBasalStatus.OBSERVED_WITH_GAPS
149+
}
150+
return AapsV3ReportedBasalRead(status, revision, segments, gaps)
151+
}
152+
}

‎android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3TargetChannel.kt‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ data class AapsV3ConnectionState(
2424
val observedInsulin: AapsV3ObservedInsulinStatus = AapsV3ObservedInsulinStatus.UNCHECKED,
2525
val insulinRecordCount: Int = 0,
2626
val observedProfileHistory: AapsV3ObservedProfileStatus = AapsV3ObservedProfileStatus.UNCHECKED,
27-
val profileHistoryIntervalCount: Int = 0
27+
val profileHistoryIntervalCount: Int = 0,
28+
val observedBasal: AapsV3ReportedBasalStatus = AapsV3ReportedBasalStatus.UNCHECKED,
29+
val basalSegmentCount: Int = 0,
30+
val basalGapCount: Int = 0
2831
)
2932

3033
/** Foreground-runtime owner. Observation never commits a preview or replays a command. */
@@ -168,7 +171,10 @@ internal class AapsV3TargetChannel(
168171
if (showEvidence) evidence?.insulin?.status ?: AapsV3ObservedInsulinStatus.UNCHECKED else AapsV3ObservedInsulinStatus.UNCHECKED,
169172
if (showEvidence) evidence?.insulin?.records?.size ?: 0 else 0,
170173
if (showEvidence) evidence?.profileHistory?.status ?: AapsV3ObservedProfileStatus.UNCHECKED else AapsV3ObservedProfileStatus.UNCHECKED,
171-
if (showEvidence) evidence?.profileHistory?.intervals?.size ?: 0 else 0)
174+
if (showEvidence) evidence?.profileHistory?.intervals?.size ?: 0 else 0,
175+
if (showEvidence) evidence?.reportedBasal?.status ?: AapsV3ReportedBasalStatus.UNCHECKED else AapsV3ReportedBasalStatus.UNCHECKED,
176+
if (showEvidence) evidence?.reportedBasal?.segments?.size ?: 0 else 0,
177+
if (showEvidence) evidence?.reportedBasal?.gaps?.size ?: 0 else 0)
172178
}
173179

174180
override suspend fun send(command: ActionCommand, deliveryGuard: suspend () -> String?): NativeTargetOutcome = commandMutex.withLock {

‎android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3EffectiveProfileSourceTest.kt‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,34 @@ class AapsV3EffectiveProfileSourceTest {
148148
assertNull(after.profile.observation)
149149
}
150150

151+
@Test fun reportedBasalSharesSnapshotAndRetainsCorrectionsDeletionAndReopenWithoutClinicalWrites() = runBlocking {
152+
val from = at - 3_600_000
153+
store().create(input("profile", from - 60_000))
154+
fun basal(rate: Double) = LocalV3DocumentInput(LocalV3Collection.TREATMENTS, "basal", "local-v3-owner", from,
155+
buildJsonObject {
156+
put("eventType", "Temp Basal"); put("date", from); put("duration", 60)
157+
put("absolute", rate); put("rate", rate); put("type", "NORMAL")
158+
}.toString())
159+
val original = store().create(basal(0.8)).document
160+
val source = AapsV3EffectiveProfileSource(store())
161+
val first = source.readEvidence(from, at, at + 200_000)
162+
assertEquals(first.insulin.retainedRevision, first.reportedBasal.retainedRevision)
163+
assertEquals(first.profileHistory.retainedRevision, first.reportedBasal.retainedRevision)
164+
assertEquals(0.8, first.reportedBasal.segments.single().reportedComponentUnits!!, 1e-12)
165+
val updated = store().update(basal(0.6), original.modifiedRevision).document
166+
assertEquals(0.6, source.readEvidence(from, at, at + 200_000)
167+
.reportedBasal.segments.single().reportedComponentUnits!!, 1e-12)
168+
store().delete(LocalV3Collection.TREATMENTS, "basal", updated.modifiedRevision)
169+
db.close(); db = open()
170+
val reopened = AapsV3EffectiveProfileSource(store()).readEvidence(from, at, at + 200_000).reportedBasal
171+
assertEquals(AapsV3ReportedBasalStatus.NOT_OBSERVED, reopened.status)
172+
assertTrue(reopened.segments.isEmpty())
173+
assertEquals(from, reopened.gaps.single().startMs)
174+
assertEquals(at, reopened.gaps.single().endMs)
175+
assertFalse(reopened.upstreamCompletenessVerified)
176+
assertTrue(db.therapyDao().since(0).isEmpty())
177+
}
178+
151179
@Test fun correctionsAndTimeMovesReplaceTheIdentityAndInvalidateTheCachedProjection() = runBlocking {
152180
val first = store().create(input("first")).document
153181
val second = store().create(input("second", at + 10_000)).document

0 commit comments

Comments
 (0)