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
53 changes: 44 additions & 9 deletions Sources/LoopAlgorithm/AlgorithmEmulationOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,42 +48,74 @@ public struct AlgorithmEmulationOptions: Equatable, Sendable {
/// momentum. Deployed Loop predates the gate, and the fixture input cannot
/// express a nil `gradualTransitionsThreshold` (absent decodes to the 40 mg/dL
/// default), so faithful emulation must switch it off here. False (default)
/// leaves `gradualTransitionsThreshold` in effect.
/// leaves `gradualTransitionsThreshold` in effect for retrospective correction
/// and the fixed 40 mg/dL gate in effect for momentum.
public var noGradualTransitionsGate: Bool

/// Disable the max-active-insulin limit on automatic dosing (the
/// `maxBolus × maxActiveInsulinMultiplier` headroom cap). Tidepool Loop 1.0's
/// temp basal recommendation had no active-insulin limit, so with IOB above the
/// limit the current port caps the temp basal below scheduled — possibly to
/// zero — where 1.0 would correct a high. False (default) enforces the limit.
public var disableMaxActiveInsulinLimit: Bool

public init(
legacyBasalIOB: Bool = false,
legacyRCDecay: Bool = false,
integralRCClamp: Bool = false,
disableIRCVelocityCeiling: Bool = false,
noGradualTransitionsGate: Bool = false
noGradualTransitionsGate: Bool = false,
disableMaxActiveInsulinLimit: Bool = false
) {
self.legacyBasalIOB = legacyBasalIOB
self.legacyRCDecay = legacyRCDecay
self.integralRCClamp = integralRCClamp
self.disableIRCVelocityCeiling = disableIRCVelocityCeiling
self.noGradualTransitionsGate = noGradualTransitionsGate
self.disableMaxActiveInsulinLimit = disableMaxActiveInsulinLimit
}

/// Emulate the algorithm as shipped in Tidepool Loop 1.0: the pre-extraction,
/// in-LoopKit algorithm behavior — every legacy behavior on, every
/// post-extraction bound off.
///
/// These options cover the prediction/effects math only. Two Tidepool Loop
/// 1.0 behaviors are expressed through existing inputs instead:
/// These options cover the prediction/effects math only. Other Tidepool
/// Loop 1.0 behaviors are expressed through existing inputs instead:
/// - It dosed exclusively by temp basal (no automatic bolus, although
/// LoopKit supported it at the time): set `recommendationType` to
/// `.tempBasal`.
/// - It had no ultra-rapid insulin types; users chose between the
/// rapid-acting adult and rapid-acting child models: set
/// `recommendationInsulinType` (and dose insulin types) to
/// `.tempBasal`. The fixture default is `.automaticBolus`.
/// - It used standard retrospective correction only (60 min effect); it
/// had no integral retrospective correction: set
/// `useIntegralRetrospectiveCorrection` to false. The two IRC options
/// above then have no effect; they matter only for emulating DIY Loop's
/// integral RC.
/// - It applied each dose's insulin effect with the ISF in effect at the
/// dose's start time: set `useMidAbsorptionISF` to false.
/// - Users chose between the rapid-acting adult and rapid-acting child
/// models: set `recommendationInsulinType` (and dose insulin types) to
/// `.rapidActingAdult` or `.rapidActingChild`.
///
/// Callers must also reproduce these parts of Tidepool Loop 1.0's dosing
/// themselves, since they sit outside this package:
/// - Overrides were applied to the basal, ISF and correction range
/// schedules before dosing. 1.0 checked override expiry against the
/// current time, so an override active at decision time covered every
/// forecast point, even past its end: extend it to the end of the
/// `target` timeline.
/// - The correction was computed at the latest CGM timestamp, including
/// the current glucose in the suspend and minimum-glucose checks: set
/// `predictionStart` to that timestamp.
/// - The recommended rate was rounded to a rate the pump supports, and a
/// running temp basal at the same rate with more than 11 minutes left
/// was continued rather than reissued.
/// - No recommendation was made while a bolus was in progress.
public static let loop1 = AlgorithmEmulationOptions(
legacyBasalIOB: true,
legacyRCDecay: true,
integralRCClamp: true,
disableIRCVelocityCeiling: true,
noGradualTransitionsGate: true
noGradualTransitionsGate: true,
disableMaxActiveInsulinLimit: true
)
}

Expand All @@ -96,6 +128,7 @@ extension AlgorithmEmulationOptions: Codable {
case integralRCClamp
case disableIRCVelocityCeiling
case noGradualTransitionsGate
case disableMaxActiveInsulinLimit
}

public init(from decoder: Decoder) throws {
Expand All @@ -116,6 +149,7 @@ extension AlgorithmEmulationOptions: Codable {
self.integralRCClamp = try container.decodeIfPresent(Bool.self, forKey: .integralRCClamp) ?? false
self.disableIRCVelocityCeiling = try container.decodeIfPresent(Bool.self, forKey: .disableIRCVelocityCeiling) ?? false
self.noGradualTransitionsGate = try container.decodeIfPresent(Bool.self, forKey: .noGradualTransitionsGate) ?? false
self.disableMaxActiveInsulinLimit = try container.decodeIfPresent(Bool.self, forKey: .disableMaxActiveInsulinLimit) ?? false
}

public func encode(to encoder: Encoder) throws {
Expand All @@ -130,5 +164,6 @@ extension AlgorithmEmulationOptions: Codable {
try container.encode(integralRCClamp, forKey: .integralRCClamp)
try container.encode(disableIRCVelocityCeiling, forKey: .disableIRCVelocityCeiling)
try container.encode(noGradualTransitionsGate, forKey: .noGradualTransitionsGate)
try container.encode(disableMaxActiveInsulinLimit, forKey: .disableMaxActiveInsulinLimit)
}
}
8 changes: 6 additions & 2 deletions Sources/LoopAlgorithm/Glucose/GlucoseMath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,18 +107,22 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In
/// - duration: The duration of the effects
/// - delta: The time differential for the returned values
/// - velocityMaximum: The limit on how fast the momentum effect can be. Defaults to 4 mg/dL/min based on physiological rates, if nil passed.
/// - gradualTransitionThreshold: Maximum allowed difference between consecutive readings in mg/dL; momentum is
/// not computed across a larger jump. Nil disables the gate, as in deployed Loop before the gate was added.
/// - Returns: An array of glucose effects
public func linearMomentumEffect(
duration: TimeInterval = GlucoseMath.momentumDuration,
delta: TimeInterval = GlucoseMath.defaultDelta,
velocityMaximum: LoopQuantity? = nil
velocityMaximum: LoopQuantity? = nil,
gradualTransitionThreshold: Double? = 40.0
) -> [GlucoseEffect] {

let velocityMax = velocityMaximum ?? LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 4.0)

guard
self.count > 2, // Linear regression isn't much use without 3 or more entries.
hasGradualTransitions() && isContinuous() && !containsCalibrations() && hasSingleProvenance,
gradualTransitionThreshold.map({ hasGradualTransitions(gradualTransitionThreshold: $0) }) ?? true,
isContinuous() && !containsCalibrations() && hasSingleProvenance,
let firstSample = self.first,
let lastSample = self.last,
let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([lastSample], duration: duration, delta: delta)
Expand Down
15 changes: 12 additions & 3 deletions Sources/LoopAlgorithm/LoopAlgorithm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,10 @@ public struct LoopAlgorithm {
var useMomentum: Bool = true
if algorithmEffectsOptions.contains(.momentum) {
let momentumInputData = glucoseHistory.filterDateRange(start.addingTimeInterval(-GlucoseMath.momentumDataInterval), start)
momentumEffects = momentumInputData.linearMomentumEffect(velocityMaximum: momentumVelocityMaximum)
momentumEffects = momentumInputData.linearMomentumEffect(
velocityMaximum: momentumVelocityMaximum,
gradualTransitionThreshold: emulation.noGradualTransitionsGate ? nil : 40.0
)
if !includingPositiveVelocityAndRC, let netMomentum = momentumEffects.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 {
// positive momentum is turned off
useMomentum = false
Expand Down Expand Up @@ -545,7 +548,10 @@ public struct LoopAlgorithm {
let momentumInputData = glucoseHistory.filterDateRange(
start.addingTimeInterval(-GlucoseMath.momentumDataInterval), start
)
momentumEffects = momentumInputData.linearMomentumEffect(velocityMaximum: momentumVelocityMaximum)
momentumEffects = momentumInputData.linearMomentumEffect(
velocityMaximum: momentumVelocityMaximum,
gradualTransitionThreshold: emulation.noGradualTransitionsGate ? nil : 40.0
)
if !includingPositiveVelocityAndRC,
let netMomentum = momentumEffects.netEffect(),
netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 {
Expand Down Expand Up @@ -813,7 +819,10 @@ public struct LoopAlgorithm {
sensitivity: sensitivityForDosing,
insulinModel: input.recommendationInsulinModel)

let maxActiveInsulin = input.maxBolus * (input.maxActiveInsulinMultiplier ?? 2)
let emulation = input.emulation ?? AlgorithmEmulationOptions()
let maxActiveInsulin = emulation.disableMaxActiveInsulinLimit
? Double.infinity
: input.maxBolus * (input.maxActiveInsulinMultiplier ?? 2)

switch input.recommendationType {
case .manualBolus:
Expand Down
82 changes: 82 additions & 0 deletions Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class AlgorithmEmulationTests: XCTestCase {
XCTAssertFalse(options.integralRCClamp)
XCTAssertFalse(options.disableIRCVelocityCeiling)
XCTAssertFalse(options.noGradualTransitionsGate)
XCTAssertFalse(options.disableMaxActiveInsulinLimit)
}

func testUnknownPresetThrows() {
Expand Down Expand Up @@ -142,6 +143,87 @@ final class AlgorithmEmulationTests: XCTestCase {
XCTAssertGreaterThan(maxDiff, 0.1, "Legacy decay should differ for unaligned sample timestamps")
}

// MARK: - Momentum gradual-transitions gate

private func momentumSamples(jumpAt jumpIndex: Int, jump: Double) -> [FixtureGlucoseSample] {
let start = fmt.date(from: "2020-01-01T00:00:00")!
return (0..<4).map { i in
let value = 120 + Double(i) * 5 + (i >= jumpIndex ? jump : 0)
return FixtureGlucoseSample(
startDate: start.addingTimeInterval(Double(i) * 5 * 60),
quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: value)
)
}
}

func testMomentumGateDropsEffectAcrossLargeJumpByDefault() {
let samples = momentumSamples(jumpAt: 2, jump: 45)
XCTAssertTrue(samples.linearMomentumEffect().isEmpty)
XCTAssertTrue(samples.linearMomentumEffect(gradualTransitionThreshold: 40).isEmpty)
}

func testNilThresholdDisablesMomentumGate() {
// Deployed Loop 1.0 computed momentum across any jump (LoopKit
// GlucoseMath.linearMomentumEffect guarded only continuity, calibration
// and provenance).
let samples = momentumSamples(jumpAt: 2, jump: 45)
XCTAssertFalse(samples.linearMomentumEffect(gradualTransitionThreshold: nil).isEmpty)
}

func testMomentumGateIsNoOpWithoutLargeJump() {
let samples = momentumSamples(jumpAt: 2, jump: 0)
let gated = samples.linearMomentumEffect()
let ungated = samples.linearMomentumEffect(gradualTransitionThreshold: nil)
XCTAssertFalse(gated.isEmpty)
XCTAssertEqual(gated, ungated)
}

// MARK: - Max active insulin limit

private func highTempBasalFixture(maxBolus: Double, emulation: AlgorithmEmulationOptions?) throws -> AlgorithmInputFixture {
// The suspend scenario with its glucose history raised 150 mg/dL, so the
// forecast is well above range and the temp basal wants to go high.
let url = Bundle.module.url(forResource: "suspend_scenario", withExtension: "json", subdirectory: "Fixtures")!
var json = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as! [String: Any]
json["recommendationType"] = "tempBasal"
json["maxBolus"] = maxBolus
json["glucoseHistory"] = (json["glucoseHistory"] as! [[String: Any]]).map { sample in
var sample = sample
sample["value"] = (sample["value"] as! Double) + 150
return sample
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
var fixture = try decoder.decode(AlgorithmInputFixture.self, from: try JSONSerialization.data(withJSONObject: json))
fixture.emulation = emulation
return fixture
}

private func tempBasalRate(_ output: AlgorithmOutput<FixtureCarbEntry>) throws -> Double {
let recommendation = try output.recommendationResult.get()
return try XCTUnwrap(recommendation.automatic?.basalAdjustment.unitsPerHour)
}

func testDisableMaxActiveInsulinLimitRemovesTempBasalCap() throws {
// With a tiny maxBolus the active-insulin limit binds: the port caps the
// temp basal at or below scheduled despite a forecast high. Tidepool Loop
// 1.0 had no such limit, so the emulated rate must be uncapped and match
// the rate with an unbounded limit.
let capped = LoopAlgorithm.run(input: try highTempBasalFixture(maxBolus: 0.05, emulation: nil))
let uncapped = LoopAlgorithm.run(input: try highTempBasalFixture(
maxBolus: 0.05, emulation: AlgorithmEmulationOptions(disableMaxActiveInsulinLimit: true)))
let reference = LoopAlgorithm.run(input: try highTempBasalFixture(maxBolus: 1000, emulation: nil))

XCTAssertGreaterThan(try tempBasalRate(uncapped), try tempBasalRate(capped))
XCTAssertEqual(try tempBasalRate(uncapped), try tempBasalRate(reference), accuracy: 1e-9)
}

func testLoop1PresetIncludesNewFlags() {
XCTAssertTrue(AlgorithmEmulationOptions.loop1.noGradualTransitionsGate)
XCTAssertTrue(AlgorithmEmulationOptions.loop1.disableMaxActiveInsulinLimit)
XCTAssertFalse(AlgorithmEmulationOptions().disableMaxActiveInsulinLimit)
}

// MARK: - Insulin model presets (Tidepool Loop 1.0 adult/child choice)

func testRapidActingModelPresetsSelectableAsInsulinTypes() throws {
Expand Down
Loading