Skip to content

Commit 0c405eb

Browse files
Add CI and TestFlight workflows for Octreotide feature
1 parent 1cbe0ad commit 0c405eb

File tree

8 files changed

+871
-0
lines changed

8 files changed

+871
-0
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Octreotide CI
2+
3+
on:
4+
push:
5+
branches: [ feature/octreotide-algorithm ]
6+
paths:
7+
- 'Experimental/Octreotide/**'
8+
pull_request:
9+
branches: [ main ]
10+
paths:
11+
- 'Experimental/Octreotide/**'
12+
workflow_dispatch: # Allow manual triggers
13+
14+
jobs:
15+
test:
16+
name: Run Tests
17+
runs-on: macos-latest
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
with:
22+
submodules: recursive
23+
24+
- name: Select Xcode
25+
run: sudo xcode-select -s /Applications/Xcode.app
26+
27+
- name: List Available Schemes
28+
run: xcodebuild -workspace LoopWorkspace.xcworkspace -list
29+
30+
- name: Build and Test
31+
run: |
32+
xcodebuild test \
33+
-workspace LoopWorkspace.xcworkspace \
34+
-scheme LoopWorkspace \
35+
-destination 'platform=iOS Simulator,name=iPhone 14,OS=latest' \
36+
-enableCodeCoverage YES \
37+
-resultBundlePath TestResults.xcresult
38+
39+
- name: Upload Test Results
40+
if: always()
41+
uses: actions/upload-artifact@v3
42+
with:
43+
name: test-results
44+
path: TestResults.xcresult
45+
46+
- name: Generate Coverage Report
47+
if: success()
48+
run: |
49+
xcrun xccov view --report TestResults.xcresult > coverage.txt
50+
51+
- name: Upload Coverage
52+
if: success()
53+
uses: actions/upload-artifact@v3
54+
with:
55+
name: code-coverage
56+
path: coverage.txt
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: Build and Upload to TestFlight
2+
3+
on:
4+
workflow_dispatch: # Manual trigger only for safety
5+
inputs:
6+
build_type:
7+
description: 'Build Type'
8+
required: true
9+
default: 'development'
10+
type: choice
11+
options:
12+
- development
13+
- release
14+
15+
jobs:
16+
build-and-upload:
17+
name: Build and Upload to TestFlight
18+
runs-on: macos-latest
19+
20+
# Only run if required secrets are available
21+
if: |
22+
secrets.TEAMID != '' &&
23+
secrets.FASTLANE_KEY_ID != '' &&
24+
secrets.FASTLANE_ISSUER_ID != '' &&
25+
secrets.FASTLANE_KEY != ''
26+
27+
steps:
28+
- uses: actions/checkout@v4
29+
with:
30+
submodules: recursive
31+
32+
- name: Select Xcode
33+
run: sudo xcode-select -s /Applications/Xcode.app
34+
35+
- name: Install Fastlane
36+
run: |
37+
gem install bundler
38+
bundle install
39+
40+
- name: Setup Provisioning
41+
env:
42+
TEAMID: ${{ secrets.TEAMID }}
43+
FASTLANE_KEY_ID: ${{ secrets.FASTLANE_KEY_ID }}
44+
FASTLANE_ISSUER_ID: ${{ secrets.FASTLANE_ISSUER_ID }}
45+
FASTLANE_KEY: ${{ secrets.FASTLANE_KEY }}
46+
GH_PAT: ${{ secrets.GH_PAT }}
47+
run: |
48+
bundle exec fastlane setup
49+
50+
- name: Build and Upload
51+
env:
52+
TEAMID: ${{ secrets.TEAMID }}
53+
FASTLANE_KEY_ID: ${{ secrets.FASTLANE_KEY_ID }}
54+
FASTLANE_ISSUER_ID: ${{ secrets.FASTLANE_ISSUER_ID }}
55+
FASTLANE_KEY: ${{ secrets.FASTLANE_KEY }}
56+
BUILD_TYPE: ${{ github.event.inputs.build_type }}
57+
run: |
58+
if [ "$BUILD_TYPE" = "release" ]; then
59+
bundle exec fastlane release
60+
else
61+
bundle exec fastlane beta
62+
fi
63+
64+
- name: Upload IPA
65+
uses: actions/upload-artifact@v3
66+
with:
67+
name: loop-ipa
68+
path: |
69+
Loop.ipa
70+
ExportOptions.plist
71+
72+
- name: Upload Build Logs
73+
if: always()
74+
uses: actions/upload-artifact@v3
75+
with:
76+
name: build-logs
77+
path: buildlog/
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import Foundation
2+
3+
/// Simulates glucose patterns for testing the Octreotide algorithm
4+
struct GlucoseSimulator {
5+
// Base glucose level (mg/dL)
6+
private var baseLevel: Double
7+
// Current trend direction (-1 to 1)
8+
private var trendDirection: Double
9+
// Noise amplitude (mg/dL)
10+
private var noiseAmplitude: Double
11+
12+
init(baseLevel: Double = 80.0, trendDirection: Double = 0.0, noiseAmplitude: Double = 2.0) {
13+
self.baseLevel = baseLevel
14+
self.trendDirection = trendDirection
15+
self.noiseAmplitude = noiseAmplitude
16+
}
17+
18+
/// Generate simulated glucose values
19+
/// - Parameters:
20+
/// - count: Number of samples to generate
21+
/// - intervalMinutes: Minutes between samples
22+
func generateSamples(count: Int, intervalMinutes: Double = 5.0) -> [Double] {
23+
var samples: [Double] = []
24+
var currentLevel = baseLevel
25+
26+
for _ in 0..<count {
27+
// Add random noise
28+
let noise = Double.random(in: -noiseAmplitude...noiseAmplitude)
29+
30+
// Add trend
31+
let trendChange = trendDirection * intervalMinutes
32+
33+
currentLevel += trendChange + noise
34+
samples.append(currentLevel)
35+
36+
// Randomly adjust trend direction slightly
37+
trendDirection += Double.random(in: -0.1...0.1)
38+
trendDirection = max(-1.0, min(1.0, trendDirection))
39+
}
40+
41+
return samples
42+
}
43+
44+
/// Generate a falling glucose pattern
45+
static func fallingPattern(from startLevel: Double = 90.0) -> GlucoseSimulator {
46+
return GlucoseSimulator(baseLevel: startLevel, trendDirection: -0.5)
47+
}
48+
49+
/// Generate a rising glucose pattern
50+
static func risingPattern(from startLevel: Double = 70.0) -> GlucoseSimulator {
51+
return GlucoseSimulator(baseLevel: startLevel, trendDirection: 0.5)
52+
}
53+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// OctreotideAlgorithm.swift
2+
// Reverse-Logic control module for Octreotide infusion
3+
// Designed as a drop-in module for Loop (developer preview, simulation only).
4+
// IMPORTANT: This is a clinical experiment template. Do NOT run closed-loop on a patient without
5+
// rigorous simulation, clinical oversight, and regulatory/safety approval.
6+
7+
import Foundation
8+
9+
/// Represents a recommended change in medication delivery
10+
struct DeliveryRecommendation {
11+
let basalRate: Double // U/hr
12+
let bolus: Double // U
13+
let messages: [String] // User/clinician messages
14+
let trend: Double // mg/dL/min
15+
let safetyFlags: Set<SafetyFlag>
16+
17+
enum SafetyFlag: String {
18+
case insufficientHistory = "Insufficient glucose history"
19+
case rapidChange = "Rapid glucose change detected"
20+
case bolusCap = "Daily bolus cap reached"
21+
case bolusDelay = "Minimum time between boluses not met"
22+
case criticalLow = "Critical low glucose"
23+
case pdParamsInvalid = "PD parameters invalid or missing"
24+
}
25+
}
26+
27+
/// Algorithm for Octreotide (somatostatin analog) delivery based on CGM trend
28+
struct OctreotideAlgorithm {
29+
// MARK: - Tunable parameters (clinician-adjustable)
30+
31+
/// Glucose target range (mg/dL)
32+
var targetLow: Double = 70.0
33+
var targetHigh: Double = 100.0
34+
var criticalLow: Double = 65.0
35+
36+
/// Basal delivery limits (U/hr)
37+
var minBasal: Double = 0.25
38+
var maxBasal: Double = 5.0
39+
40+
/// Basal multipliers for different glucose ranges
41+
var basalMultLow: Double = 1.5 // Multiply scheduled basal by this when low
42+
var basalMultHigh: Double = 0.5 // Multiply by this when high/rising
43+
44+
/// Trend thresholds (mg/dL per min) - negative is falling
45+
var trendFallFast: Double = -1.0
46+
var trendRiseFast: Double = 1.0
47+
48+
/// Bolus configuration
49+
var bolusUnit: Double = 1.0 // Standard bolus size (U)
50+
var bolusRepeatDelay: TimeInterval = 15 * 60 // Min seconds between boluses
51+
var maxDailyBolus: Double = 30.0 // Maximum total bolus units per day
52+
53+
/// Pharmacodynamic parameters (required for safety)
54+
var pdOnsetMinutes: Double = 30.0 // Time to start of action
55+
var pdPeakMinutes: Double = 90.0 // Time to peak action
56+
var pdDurationMinutes: Double = 240.0 // Total duration of action
57+
58+
// MARK: - Utility
59+
60+
private func clamp(_ value: Double, _ minVal: Double, _ maxVal: Double) -> Double {
61+
return max(minVal, min(value, maxVal))
62+
}
63+
64+
/// Compute trend in mg/dL per minute using 3 samples
65+
/// - Parameter glucoseHistory: Ordered oldest to newest, ~5min spacing
66+
private func computeTrendRate(glucoseHistory: [Double]) -> Double {
67+
guard glucoseHistory.count >= 3 else { return 0.0 }
68+
let last = glucoseHistory[glucoseHistory.count - 1]
69+
let thirdLast = glucoseHistory[glucoseHistory.count - 3]
70+
// Approximate over ~10 minutes
71+
return (last - thirdLast) / 10.0
72+
}
73+
74+
/// Validate PD parameters are present and reasonable
75+
private func validatePDParams() -> Bool {
76+
guard pdOnsetMinutes > 0,
77+
pdPeakMinutes > pdOnsetMinutes,
78+
pdDurationMinutes > pdPeakMinutes else {
79+
return false
80+
}
81+
return true
82+
}
83+
84+
/// Main recommendation function
85+
/// - Parameters:
86+
/// - glucoseNow: Current glucose in mg/dL
87+
/// - glucoseHistory: Recent glucose samples (mg/dL), oldest->newest, ~5min spacing
88+
/// - scheduledBasal: Current scheduled basal rate (U/hr)
89+
/// - lastBolusDate: Time of last bolus (if any)
90+
/// - dailyBolusTotal: Total bolus units given today
91+
/// - Returns: Recommended basal rate, bolus amount, and status messages
92+
func computeRecommendation(
93+
glucoseNow: Double,
94+
glucoseHistory: [Double],
95+
scheduledBasal: Double,
96+
lastBolusDate: Date?,
97+
dailyBolusTotal: Double
98+
) -> DeliveryRecommendation {
99+
// Start with current basal as baseline
100+
var recommendedBasal = scheduledBasal
101+
var recommendedBolus: Double = 0.0
102+
var messages: [String] = []
103+
var safetyFlags: Set<DeliveryRecommendation.SafetyFlag> = []
104+
105+
// Compute trend (mg/dL/min)
106+
let trend = computeTrendRate(glucoseHistory: glucoseHistory)
107+
108+
// MARK: Safety Checks
109+
110+
// 1. Check glucose history
111+
guard glucoseHistory.count >= 3 else {
112+
safetyFlags.insert(.insufficientHistory)
113+
return DeliveryRecommendation(
114+
basalRate: minBasal,
115+
bolus: 0,
116+
messages: ["Insufficient glucose history for safe automation"],
117+
trend: 0,
118+
safetyFlags: safetyFlags
119+
)
120+
}
121+
122+
// 2. Validate PD parameters
123+
guard validatePDParams() else {
124+
safetyFlags.insert(.pdParamsInvalid)
125+
return DeliveryRecommendation(
126+
basalRate: minBasal,
127+
bolus: 0,
128+
messages: ["Invalid pharmacodynamic parameters - check configuration"],
129+
trend: trend,
130+
safetyFlags: safetyFlags
131+
)
132+
}
133+
134+
// 3. Check for rapid changes
135+
if abs(trend) > max(abs(trendFallFast), abs(trendRiseFast)) {
136+
safetyFlags.insert(.rapidChange)
137+
messages.append("Rapid glucose change detected")
138+
}
139+
140+
// 4. Check critical low
141+
if glucoseNow <= criticalLow {
142+
safetyFlags.insert(.criticalLow)
143+
// Max out basal but no bolus
144+
recommendedBasal = maxBasal
145+
messages.append("Critical low - maximizing basal delivery")
146+
return DeliveryRecommendation(
147+
basalRate: recommendedBasal,
148+
bolus: 0,
149+
messages: messages,
150+
trend: trend,
151+
safetyFlags: safetyFlags
152+
)
153+
}
154+
155+
// MARK: Basal Adjustments
156+
157+
// Adjust basal rate based on current glucose and trend
158+
if glucoseNow < targetLow || trend < trendFallFast {
159+
// Low or falling fast - increase basal
160+
recommendedBasal = scheduledBasal * basalMultLow
161+
messages.append("Increasing basal due to low/falling glucose")
162+
} else if glucoseNow > targetHigh || trend > trendRiseFast {
163+
// High or rising fast - decrease basal
164+
recommendedBasal = scheduledBasal * basalMultHigh
165+
messages.append("Decreasing basal due to high/rising glucose")
166+
}
167+
168+
// Clamp basal rate to limits
169+
recommendedBasal = clamp(recommendedBasal, minBasal, maxBasal)
170+
171+
// MARK: Bolus Logic
172+
173+
// Helper to check bolus eligibility
174+
func canGiveBolus() -> Bool {
175+
if dailyBolusTotal + bolusUnit > maxDailyBolus {
176+
safetyFlags.insert(.bolusCap)
177+
return false
178+
}
179+
if let last = lastBolusDate {
180+
if Date().timeIntervalSince(last) < bolusRepeatDelay {
181+
safetyFlags.insert(.bolusDelay)
182+
return false
183+
}
184+
}
185+
return true
186+
}
187+
188+
// Consider bolus for significant lows or rapid drops
189+
if (glucoseNow < targetLow && trend < 0) || trend < trendFallFast {
190+
if canGiveBolus() {
191+
recommendedBolus = bolusUnit
192+
messages.append("Recommending bolus for low/falling glucose")
193+
} else {
194+
messages.append("Bolus indicated but safety cap prevents delivery")
195+
}
196+
}
197+
198+
return DeliveryRecommendation(
199+
basalRate: recommendedBasal,
200+
bolus: recommendedBolus,
201+
messages: messages,
202+
trend: trend,
203+
safetyFlags: safetyFlags
204+
)
205+
}
206+
}

0 commit comments

Comments
 (0)