From 97d611b7c5a023583add47163ef8cca887940469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 30 Oct 2026 10:17:21 +0100 Subject: [PATCH] Draw basal as 0 while a Trio pump is suspended --- .../Controllers/Nightscout/Treatments.swift | 9 +- .../Nightscout/Treatments/Basals.swift | 7 +- .../Treatments/PumpSuspension.swift | 86 ++++++++++++++ Tests/PumpSuspensionTests.swift | 108 ++++++++++++++++++ 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 LoopFollow/Controllers/Nightscout/Treatments/PumpSuspension.swift create mode 100644 Tests/PumpSuspensionTests.swift diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 430751bc4..2c2ef0cb6 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -138,7 +138,14 @@ extension MainViewController { } if tempBasal.count > 0 { - processNSBasals(entries: tempBasal) + let suspensions = PumpSuspension.intervals( + notes: note.compactMap { entry in + guard let date = NightscoutUtils.treatmentDate(from: entry), let text = entry["notes"] as? String else { return nil } + return (date, text) + }, + tempBasalStarts: tempBasal.compactMap { NightscoutUtils.treatmentDate(from: $0) } + ) + processNSBasals(entries: tempBasal, suspensions: suspensions) } else { if basalData.count > 0 { clearOldTempBasal() diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 405281926..ba811a231 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -5,7 +5,7 @@ import Foundation extension MainViewController { // NS Temp Basal Response Processor - func processNSBasals(entries: [[String: AnyObject]]) { + func processNSBasals(entries: [[String: AnyObject]], suspensions: [DateInterval] = []) { infoManager.clearInfoData(type: .basal) basalData.removeAll() @@ -137,6 +137,11 @@ extension MainViewController { basalData.append(endDot) } + basalData = PumpSuspension.applying(suspensions, to: basalData) + if suspensions.contains(where: { $0.contains(Date()) }) { + latestBasal = Localizer.formatToLocalizedString(0.0, maxFractionDigits: 2, minFractionDigits: 0) + } + if Storage.shared.graphBasal.value { updateBasalGraph() } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/PumpSuspension.swift b/LoopFollow/Controllers/Nightscout/Treatments/PumpSuspension.swift new file mode 100644 index 000000000..b31789773 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/Treatments/PumpSuspension.swift @@ -0,0 +1,86 @@ +// LoopFollow +// PumpSuspension.swift + +import Foundation + +/// Periods when the pump delivers no basal, from Trio's `PumpSuspend` and `PumpResume` notes. +enum PumpSuspension { + static let suspendNote = "PumpSuspend" + static let resumeNote = "PumpResume" + + /// Pairs each suspend with the next resume. A suspend without a resume lasts until the next + /// temp basal starts, as Trio sets no temp basal while the pump is suspended. Without either it + /// is still ongoing and ends at `.distantFuture`. + static func intervals(notes: [(date: Date, note: String)], tempBasalStarts: [Date]) -> [DateInterval] { + let events = notes + .filter { $0.note == suspendNote || $0.note == resumeNote } + .sorted { $0.date < $1.date } + let tempStarts = tempBasalStarts.sorted() + + var result: [DateInterval] = [] + var suspendStart: Date? + + func close(at resume: Date?) { + guard let start = suspendStart else { return } + let nextTemp = tempStarts.first { $0 > start } + let end = [resume, nextTemp].compactMap { $0 }.min() ?? .distantFuture + if end > start { + result.append(DateInterval(start: start, end: end)) + } + suspendStart = nil + } + + for event in events { + if event.note == suspendNote { + if let start = suspendStart, let nextTemp = tempStarts.first(where: { $0 > start }), nextTemp <= event.date { + close(at: nil) + } + if suspendStart == nil { + suspendStart = event.date + } + } else { + close(at: event.date) + } + } + close(at: nil) + return result + } + + /// The basal steps with the rate set to 0 inside the intervals. Each point's rate holds until + /// the next point, as the chart draws them. + static func applying(_ intervals: [DateInterval], to basal: [MainViewController.basalGraphStruct]) -> [MainViewController.basalGraphStruct] { + guard !intervals.isEmpty else { return basal } + let points = basal.sorted { $0.date < $1.date } + let suspensions = intervals.sorted { $0.start < $1.start } + var result: [MainViewController.basalGraphStruct] = [] + + for (index, point) in points.enumerated() where index + 1 < points.count { + let end = points[index + 1].date + guard end > point.date else { continue } + + var cursor = point.date + for suspension in suspensions { + let suspendStart = max(suspension.start.timeIntervalSince1970, cursor) + let suspendEnd = min(suspension.end.timeIntervalSince1970, end) + guard suspendStart < suspendEnd else { continue } + if cursor < suspendStart { + result.append(.init(basalRate: point.basalRate, date: cursor)) + result.append(.init(basalRate: point.basalRate, date: suspendStart)) + } + result.append(.init(basalRate: 0, date: suspendStart)) + result.append(.init(basalRate: 0, date: suspendEnd)) + cursor = suspendEnd + } + if cursor < end { + result.append(.init(basalRate: point.basalRate, date: cursor)) + result.append(.init(basalRate: point.basalRate, date: end)) + } + } + if let last = points.last { + let lastDate = Date(timeIntervalSince1970: last.date) + let suspended = suspensions.contains { $0.start <= lastDate && lastDate < $0.end } + result.append(.init(basalRate: suspended ? 0 : last.basalRate, date: last.date)) + } + return result + } +} diff --git a/Tests/PumpSuspensionTests.swift b/Tests/PumpSuspensionTests.swift new file mode 100644 index 000000000..75efb56cd --- /dev/null +++ b/Tests/PumpSuspensionTests.swift @@ -0,0 +1,108 @@ +// LoopFollow +// PumpSuspensionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct PumpSuspensionTests { + private let t0 = Date(timeIntervalSince1970: 1_790_000_000) + + private func at(_ minutes: Double) -> Date { + t0.addingTimeInterval(minutes * 60) + } + + private func note(_ text: String, _ minutes: Double) -> (date: Date, note: String) { + (at(minutes), text) + } + + private func dot(_ rate: Double, _ minutes: Double) -> MainViewController.basalGraphStruct { + .init(basalRate: rate, date: at(minutes).timeIntervalSince1970) + } + + /// The rate the chart draws at a time: each point holds until the next one. + private func rate(at minutes: Double, in basal: [MainViewController.basalGraphStruct]) -> Double? { + let time = at(minutes).timeIntervalSince1970 + return basal.sorted { $0.date < $1.date }.last { $0.date <= time }?.basalRate + } + + // MARK: - Intervals + + @Test("A suspend pairs with the next resume") + func pairsSuspendAndResume() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10), note("Pizza", 15), note("PumpResume", 40)], + tempBasalStarts: [at(0), at(45)] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(40))]) + } + + @Test("A suspend without a resume ends at the next temp basal") + func endsAtNextTempBasal() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10)], + tempBasalStarts: [at(0), at(30)] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(30))]) + } + + @Test("A suspend without a resume or a later temp basal is ongoing") + func ongoingSuspend() { + let intervals = PumpSuspension.intervals(notes: [note("PumpSuspend", 10)], tempBasalStarts: [at(0)]) + #expect(intervals == [DateInterval(start: at(10), end: .distantFuture)]) + } + + @Test("A repeated suspend keeps the first start") + func repeatedSuspend() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10), note("PumpSuspend", 20), note("PumpResume", 40)], + tempBasalStarts: [] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(40))]) + } + + @Test("A suspend ended by a temp basal is separate from a later suspend") + func suspendAfterTempBasal() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10), note("PumpSuspend", 50), note("PumpResume", 60)], + tempBasalStarts: [at(30)] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(30)), DateInterval(start: at(50), end: at(60))]) + } + + @Test("A resume without a suspend is ignored") + func resumeWithoutSuspend() { + let intervals = PumpSuspension.intervals(notes: [note("PumpResume", 10)], tempBasalStarts: []) + #expect(intervals.isEmpty) + } + + // MARK: - Basal steps + + @Test("Basal inside a suspension is drawn as 0") + func zeroInsideSuspension() { + let basal = [dot(1.2, 0), dot(1.2, 30), dot(0.8, 30), dot(0.8, 60)] + let result = PumpSuspension.applying([DateInterval(start: at(20), end: at(40))], to: basal) + #expect(rate(at: 10, in: result) == 1.2) + #expect(rate(at: 25, in: result) == 0) + #expect(rate(at: 35, in: result) == 0) + #expect(rate(at: 50, in: result) == 0.8) + #expect(result.map(\.date).max() == at(60).timeIntervalSince1970) + } + + @Test("An ongoing suspension holds 0 to the end of the basal") + func ongoingSuspensionToEnd() { + let basal = [dot(1.0, 0), dot(1.0, 60)] + let result = PumpSuspension.applying([DateInterval(start: at(30), end: .distantFuture)], to: basal) + #expect(rate(at: 15, in: result) == 1.0) + #expect(rate(at: 45, in: result) == 0) + #expect(result.map(\.date).max() == at(60).timeIntervalSince1970) + } + + @Test("Basal without suspensions is unchanged") + func noSuspensions() { + let basal = [dot(1.0, 0), dot(1.0, 60)] + let result = PumpSuspension.applying([], to: basal) + #expect(result.map(\.date) == basal.map(\.date)) + #expect(result.map(\.basalRate) == basal.map(\.basalRate)) + } +}