Skip to content
Merged
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: 8 additions & 1 deletion LoopFollow/Controllers/Nightscout/Treatments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 6 additions & 1 deletion LoopFollow/Controllers/Nightscout/Treatments/Basals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
108 changes: 108 additions & 0 deletions Tests/PumpSuspensionTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}