Просмотр исходного кода

Merge pull request #349 from nightscout/glucose-sim

Refactor glucose simulator to imitate a sinusoidal glucose pattern for better testing purposes
Deniz Cengiz 1 год назад
Родитель
Сommit
18dfa4a2b8

+ 185 - 90
Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift

@@ -10,15 +10,7 @@
 ///
 /// class GlucoseSimulatorSource - main class
 /// protocol BloodGlucoseGenerator
-///  - IntelligentGenerator: BloodGlucoseGenerator
-
-// TODO: Every itteration trend make two steps, but must only one
-
-// TODO: Trend's value sticks to max and min Glucose value (in Glucose Generator)
-
-// TODO: Add reaction to insulin
-
-// TODO: Add probability to set trend's target value. Middle values must have more probability, than max and min.
+///  - OscillatingGenerator: BloodGlucoseGenerator - Generates sinusoidal glucose values around a center point
 
 import Combine
 import Foundation
@@ -26,22 +18,29 @@ import LoopKitUI
 
 // MARK: - Glucose simulator
 
+/// A class that simulates glucose values for testing purposes.
+/// This class implements the GlucoseSource protocol and provides simulated glucose readings
+/// using different generator strategies.
 final class GlucoseSimulatorSource: GlucoseSource {
     var cgmManager: CGMManagerUI?
     var glucoseManager: FetchGlucoseManager?
 
     private enum Config {
-        // min time period to publish data
+        /// Minimum time period between data publications (in seconds)
         static let workInterval: TimeInterval = 300
-        // default BloodGlucose item at first run
-        // 288 = 1 day * 24 hours * 60 minites * 60 seconds / workInterval
+        /// Default number of blood glucose items to generate at first run
+        /// 288 = 1 day * 24 hours * 60 minutes * 60 seconds / workInterval
         static let defaultBGItems = 288
     }
 
+    /// The last glucose value that was generated
     @Persisted(key: "GlucoseSimulatorLastGlucose") private var lastGlucose = 100
 
+    /// The date of the last fetch operation
     @Persisted(key: "GlucoseSimulatorLastFetchDate") private var lastFetchDate: Date! = nil
 
+    /// Initializes the glucose simulator source
+    /// Sets up the initial fetch date if not already set
     init() {
         if lastFetchDate == nil {
             var lastDate = Date()
@@ -52,12 +51,13 @@ final class GlucoseSimulatorSource: GlucoseSource {
         }
     }
 
+    /// The glucose generator used to create simulated values
+    /// Uses OscillatingGenerator to create a sinusoidal pattern around 120 mg/dL
     private lazy var generator: BloodGlucoseGenerator = {
-        IntelligentGenerator(
-            currentGlucose: lastGlucose
-        )
+        OscillatingGenerator()
     }()
 
+    /// Determines if new glucose values can be generated based on the time elapsed since the last fetch
     private var canGenerateNewValues: Bool {
         guard let lastDate = lastFetchDate else { return true }
         if Calendar.current.dateComponents([.second], from: lastDate, to: Date()).second! >= Int(Config.workInterval) {
@@ -67,6 +67,9 @@ final class GlucoseSimulatorSource: GlucoseSource {
         }
     }
 
+    /// Fetches new glucose values if enough time has passed since the last fetch
+    /// - Parameter timer: Optional dispatch timer (not used in this implementation)
+    /// - Returns: A publisher that emits an array of BloodGlucose objects
     func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
         guard canGenerateNewValues else {
             return Just([]).eraseToAnyPublisher()
@@ -86,6 +89,8 @@ final class GlucoseSimulatorSource: GlucoseSource {
         return Just(glucoses).eraseToAnyPublisher()
     }
 
+    /// Fetches new glucose values if needed
+    /// - Returns: A publisher that emits an array of BloodGlucose objects
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
         fetch(nil)
     }
@@ -93,105 +98,195 @@ final class GlucoseSimulatorSource: GlucoseSource {
 
 // MARK: - Glucose generator
 
+/// Protocol defining the interface for glucose generators
+/// Implementations of this protocol provide different strategies for generating glucose values
 protocol BloodGlucoseGenerator {
+    /// Generates blood glucose values between the specified dates at the given interval
+    /// - Parameters:
+    ///   - startDate: The start date for generating values
+    ///   - finishDate: The end date for generating values
+    ///   - interval: The time interval between generated values
+    /// - Returns: An array of BloodGlucose objects
     func getBloodGlucoses(startDate: Date, finishDate: Date, withInterval: TimeInterval) -> [BloodGlucose]
 }
 
-class IntelligentGenerator: BloodGlucoseGenerator {
-    private enum Config {
-        // max and min glucose of trend's target
-        static let maxGlucose = 320
-        static let minGlucose = 45
+/// A glucose generator that creates a sinusoidal pattern around a center value
+/// This generator simulates a realistic oscillating glucose pattern with configurable parameters
+class OscillatingGenerator: BloodGlucoseGenerator {
+    /// Default values for simulator parameters
+    enum Defaults {
+        static let centerValue: Double = 120.0
+        static let amplitude: Double = 45.0
+        static let period: Double = 10800.0 // 3 hours in seconds
+        static let noiseAmplitude: Double = 5.0
+        static let produceStaleValues: Bool = false
     }
 
-    // target glucose of trend
-    @Persisted(key: "GlucoseSimulatorTargetValue") private var trendTargetValue = 100
-    // how many steps left in current trend
-    @Persisted(key: "GlucoseSimulatorTargetSteps") private var trendStepsLeft = 1
-    // direction of last step
-    @Persisted(key: "GlucoseSimulatorDirection") private var trandsStepDirection = BloodGlucose.Direction.flat.rawValue
-    var currentGlucose: Int
-    let startup = Date()
-    init(currentGlucose: Int) {
-        self.currentGlucose = currentGlucose
+    /// UserDefaults keys for storing simulator parameters
+    private enum UserDefaultsKeys {
+        static let centerValue = "GlucoseSimulator_CenterValue"
+        static let amplitude = "GlucoseSimulator_Amplitude"
+        static let period = "GlucoseSimulator_Period"
+        static let noiseAmplitude = "GlucoseSimulator_NoiseAmplitude"
+        static let produceStaleValues = "GlucoseSimulator_ProduceStaleValues"
     }
 
-    func getBloodGlucoses(startDate: Date, finishDate: Date, withInterval interval: TimeInterval) -> [BloodGlucose] {
-        var result = [BloodGlucose]()
-
-        var _currentDate = startDate
-        while _currentDate <= finishDate {
-            result.append(getNextBloodGlucose(forDate: _currentDate))
-            _currentDate = _currentDate.addingTimeInterval(interval)
-        }
+    /// Amplitude of the oscillation (±45 mg/dL to create range from ~80 to ~170)
+    private var amplitude: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.amplitude) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.amplitude) :
+            Defaults.amplitude }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.amplitude) }
+    }
 
-        return result
+    /// Period of the oscillation in seconds (3 hours = 10800 seconds)
+    private var period: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.period) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.period) :
+            Defaults.period }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.period) }
     }
 
-    // get next glucose's value in current trend
-    private func getNextBloodGlucose(forDate date: Date) -> BloodGlucose {
-        let previousGlucose = currentGlucose
-        makeStepInTrend()
-        trandsStepDirection = getDirection(fromGlucose: previousGlucose, toGlucose: currentGlucose).rawValue
-        let glucose = BloodGlucose(
-            _id: UUID().uuidString,
-            sgv: currentGlucose,
-            direction: BloodGlucose.Direction(rawValue: trandsStepDirection),
-            date: Decimal(Int(date.timeIntervalSince1970) * 1000),
-            dateString: date,
-            unfiltered: Decimal(currentGlucose),
-            filtered: nil,
-            noise: nil,
-            glucose: currentGlucose,
-            type: nil,
-            activationDate: startup,
-            sessionStartDate: startup,
-            transmitterID: "SIMULATOR"
-        )
-        return glucose
+    /// Center value of the oscillation (target glucose level)
+    private var centerValue: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.centerValue) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.centerValue) :
+            Defaults.centerValue }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.centerValue) }
     }
 
-    private func setNewRandomTarget() {
-        guard trendTargetValue > 0 else {
-            trendTargetValue = Array(80 ... 110).randomElement()!
-            return
-        }
-        let difference = (Array(-50 ... -20) + Array(20 ... 50)).randomElement()!
-        let _value = trendTargetValue + difference
-        if _value <= Config.minGlucose {
-            trendTargetValue = Config.minGlucose
-        } else if _value >= Config.maxGlucose {
-            trendTargetValue = Config.maxGlucose
-        } else {
-            trendTargetValue = _value
-        }
+    /// Amplitude of random noise to add to the values (±5 mg/dL)
+    private var noiseAmplitude: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.noiseAmplitude) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.noiseAmplitude) :
+            Defaults.noiseAmplitude }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.noiseAmplitude) }
     }
 
-    private func setNewRandomSteps() {
-        trendStepsLeft = Array(3 ... 8).randomElement()!
+    /// Whether to produce stale (unchanging) glucose values
+    var produceStaleValues: Bool {
+        get { UserDefaults.standard.bool(forKey: UserDefaultsKeys.produceStaleValues) }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.produceStaleValues) }
     }
 
-    private func getDirection(fromGlucose from: Int, toGlucose to: Int) -> BloodGlucose.Direction {
-        BloodGlucose.Direction(trend: Int(to - from))
+    /// Start date for the simulation
+    private let startup = Date()
+
+    /// Last generated glucose value for stale mode
+    private var lastGeneratedGlucose: Int?
+
+    /// Provides information string to describe the simulator as glucose source
+    func sourceInfo() -> [String: Any]? {
+        [GlucoseSourceKey.description.rawValue: "Glucose simulator"]
     }
 
-    private func generateNewTrend() {
-        setNewRandomTarget()
-        setNewRandomSteps()
+    /// Reset all parameters to default values
+    func resetToDefaults() {
+        centerValue = Defaults.centerValue
+        amplitude = Defaults.amplitude
+        period = Defaults.period
+        noiseAmplitude = Defaults.noiseAmplitude
+        produceStaleValues = Defaults.produceStaleValues
+        lastGeneratedGlucose = nil
     }
 
-    private func makeStepInTrend() {
-        guard trendStepsLeft > 0 else { return }
+    /// Generates blood glucose values between the specified dates at the given interval
+    /// - Parameters:
+    ///   - startDate: The start date for generating values
+    ///   - finishDate: The end date for generating values
+    ///   - interval: The time interval between generated values
+    /// - Returns: An array of BloodGlucose objects with sinusoidal pattern
+    func getBloodGlucoses(startDate: Date, finishDate: Date, withInterval interval: TimeInterval) -> [BloodGlucose] {
+        var result = [BloodGlucose]()
+        var currentDate = startDate
+
+        while currentDate <= finishDate {
+            let glucose: Int
+            let direction: BloodGlucose.Direction
+
+            if produceStaleValues, lastGeneratedGlucose != nil {
+                // In stale mode, use the last generated glucose value
+                glucose = lastGeneratedGlucose!
+                direction = .flat
+            } else {
+                // Generate a new glucose value
+                glucose = generate(date: currentDate)
+                direction = calculateDirection(at: currentDate)
+                lastGeneratedGlucose = glucose
+            }
 
-        currentGlucose +=
-            Int(Double((trendTargetValue - currentGlucose) / trendStepsLeft) * [0.3, 0.6, 1, 1.3, 1.6, 2.0].randomElement()!)
-        trendStepsLeft -= 1
-        if trendStepsLeft == 0 {
-            generateNewTrend()
+            // Create BloodGlucose with the correct constructor
+            let bloodGlucose = BloodGlucose(
+                _id: UUID().uuidString,
+                sgv: glucose,
+                direction: direction,
+                date: Decimal(Int(currentDate.timeIntervalSince1970) * 1000),
+                dateString: currentDate,
+                unfiltered: Decimal(glucose),
+                filtered: nil,
+                noise: nil,
+                glucose: glucose,
+                type: nil,
+                activationDate: startup,
+                sessionStartDate: startup,
+                transmitterID: "SIMULATOR"
+            )
+
+            result.append(bloodGlucose)
+            currentDate = currentDate.addingTimeInterval(interval)
         }
+
+        return result
     }
 
-    func sourceInfo() -> [String: Any]? {
-        [GlucoseSourceKey.description.rawValue: "Glucose simulator"]
+    /// Generates a glucose value for the specified date using a sinusoidal function
+    /// - Parameter date: The date for which to generate the glucose value
+    /// - Returns: An integer representing the glucose value in mg/dL
+    private func generate(date: Date) -> Int {
+        // Time in seconds since 1970
+        let timeSeconds = date.timeIntervalSince1970
+
+        // Calculate sine value
+        let sinValue = sin(2.0 * .pi * timeSeconds / period)
+
+        // Random noise
+        let noise = Double.random(in: -noiseAmplitude ... noiseAmplitude)
+
+        // Calculate glucose value: center + amplitude * sine + noise
+        let glucoseValue = centerValue + amplitude * sinValue + noise
+
+        // Return as integer
+        return Int(glucoseValue)
+    }
+
+    /// Calculates the direction (trend) of glucose change at the specified date
+    /// - Parameter date: The date for which to calculate the direction
+    /// - Returns: A BloodGlucose.Direction value indicating the trend
+    private func calculateDirection(at date: Date) -> BloodGlucose.Direction {
+        // Time in seconds since 1970
+        let timeSeconds = date.timeIntervalSince1970
+
+        // Calculate derivative of sine function (cosine)
+        let cosValue = cos(2.0 * .pi * timeSeconds / period)
+
+        // Slope of the curve at this point
+        let slope = -amplitude * 2.0 * .pi / period * cosValue
+
+        // Determine direction based on slope
+        if abs(slope) < 0.2 {
+            return .flat
+        } else if slope > 0 {
+            if slope > 1.0 {
+                return .singleUp
+            } else {
+                return .fortyFiveUp
+            }
+        } else {
+            if slope < -1.0 {
+                return .singleDown
+            } else {
+                return .fortyFiveDown
+            }
+        }
     }
 }

+ 55 - 3
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -12754,6 +12754,9 @@
         }
       }
     },
+    "±" : {
+
+    },
     "<  3.3 " : {
       "localizations" : {
         "ar" : {
@@ -29272,6 +29275,9 @@
         }
       }
     },
+    "Amplitude:" : {
+
+    },
     "An example of a Carb Warning is 'Carbs required: 30 g'" : {
       "localizations" : {
         "ar" : {
@@ -40896,6 +40902,9 @@
         }
       }
     },
+    "Center Value:" : {
+
+    },
     "CGM" : {
       "comment" : "CGM",
       "localizations" : {
@@ -45378,6 +45387,9 @@
         }
       }
     },
+    "Configuration changes will take effect on the next glucose reading." : {
+
+    },
     "Configure Libre Transmitter" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -81904,6 +81916,9 @@
         }
       }
     },
+    "Glucose trace WILL NOT be affected by any insulin or carb entries." : {
+
+    },
     "Glucose Trend" : {
       "localizations" : {
         "ar" : {
@@ -112863,6 +112878,9 @@
     "No Temp Target Presets" : {
 
     },
+    "Noise:" : {
+
+    },
     "Noisy CGM Target Increase" : {
       "comment" : "Noisy CGM Target Increase",
       "localizations" : {
@@ -122157,6 +122175,9 @@
         }
       }
     },
+    "Period:" : {
+
+    },
     "Persist sensordata" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -124144,6 +124165,9 @@
     "Processing…" : {
 
     },
+    "Produce Stale Values" : {
+
+    },
     "profile" : {
       "localizations" : {
         "ar" : {
@@ -126649,6 +126673,19 @@
         }
       }
     },
+    "Random variation added to each reading to simulate real-world sensor noise." : {
+
+    },
+    "Range: %@–%@ %@" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "Range: %1$@–%2$@ %3$@"
+          }
+        }
+      }
+    },
     "Rapid-Acting: 75 minutes (permitted range 50-120 minutes)" : {
       "localizations" : {
         "ar" : {
@@ -129340,6 +129377,9 @@
         }
       }
     },
+    "Reset to Defaults" : {
+
+    },
     "Resistance Lowers Target" : {
       "comment" : "Resistance Lowers Target",
       "localizations" : {
@@ -151246,6 +151286,9 @@
         }
       }
     },
+    "The average glucose level around which values will oscillate." : {
+
+    },
     "The current version has a critical issue and should be updated as soon as possible." : {
       "comment" : "Message for critical update alert"
     },
@@ -153391,6 +153434,9 @@
         }
       }
     },
+    "The maximum deviation from the center value. Higher values create wider swings." : {
+
+    },
     "The maximum duration for tracking carb entries in estimating Carbs on Board (COB)" : {
 
     },
@@ -154356,6 +154402,9 @@
         }
       }
     },
+    "The simulator creates a wave-like pattern that mimics natural glucose fluctuations throughout the day." : {
+
+    },
     "The source of the glucose reading will be added to the notification." : {
       "localizations" : {
         "ar" : {
@@ -154462,6 +154511,9 @@
         }
       }
     },
+    "The time it takes to complete one full cycle from high to low and back to high." : {
+
+    },
     "The Upload Treatments toggle enables uploading of the following data sets to your connected Nightscout URL:" : {
       "localizations" : {
         "ar" : {
@@ -168792,9 +168844,6 @@
         }
       }
     },
-    "Trio's glucose simulator does not offer any configuration. Its use is strictly for demonstration purposes only." : {
-
-    },
     "Trio's Simple Lock Screen Widget displays current glucose reading, trend arrow, delta and the timestamp of the current reading." : {
       "localizations" : {
         "ar" : {
@@ -176947,6 +176996,9 @@
         }
       }
     },
+    "When stale values are enabled, the simulator will repeatedly output the last generated glucose value." : {
+
+    },
     "When Suspend Zeros IOB is enabled, any active temporary basal rates during a pump suspension are reset, with new 0 U/hr temporary basal rates added to counteract those done during suspension." : {
       "localizations" : {
         "ar" : {

+ 257 - 36
Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift

@@ -15,14 +15,49 @@ extension CGMSettings {
 
         @State private var shouldDisplayDeletionConfirmation: Bool = false
 
+        // Simulator settings
+        @State private var centerValue: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_CenterValue")
+        @State private var amplitude: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_Amplitude")
+        @State private var period: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_Period")
+        @State private var noiseAmplitude: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_NoiseAmplitude")
+        @State private var produceStaleValues: Bool = UserDefaults.standard.bool(forKey: "GlucoseSimulator_ProduceStaleValues")
+
+        // Initialize state variables with defaults if needed
+        private func initializeSimulatorSettings() {
+            if centerValue == 0 {
+                centerValue = OscillatingGenerator.Defaults.centerValue
+            }
+            if amplitude == 0 {
+                amplitude = OscillatingGenerator.Defaults.amplitude
+            }
+            if period == 0 {
+                period = OscillatingGenerator.Defaults.period
+            }
+            if noiseAmplitude == 0 {
+                noiseAmplitude = OscillatingGenerator.Defaults.noiseAmplitude
+            }
+            // produceStaleValues is already initialized as false by default
+        }
+
+        // Save simulator settings to UserDefaults
+        private func saveSimulatorSettings() {
+            UserDefaults.standard.set(centerValue, forKey: "GlucoseSimulator_CenterValue")
+            UserDefaults.standard.set(amplitude, forKey: "GlucoseSimulator_Amplitude")
+            UserDefaults.standard.set(period, forKey: "GlucoseSimulator_Period")
+            UserDefaults.standard.set(noiseAmplitude, forKey: "GlucoseSimulator_NoiseAmplitude")
+            UserDefaults.standard.set(produceStaleValues, forKey: "GlucoseSimulator_ProduceStaleValues")
+        }
+
         var body: some View {
             NavigationView {
                 Form {
                     if cgmCurrent.type != .none {
                         if cgmCurrent.type == .nightscout {
                             nightscoutSection
-                        } else {
-                            customCGMSection
+                        } else if cgmCurrent.type == .xdrip {
+                            xDripConfigurationSection
+                        } else if cgmCurrent.type == .simulator {
+                            simulatorConfigurationSection
                         }
 
                         if let appURL = cgmCurrent.type.appURL {
@@ -64,7 +99,7 @@ extension CGMSettings {
                 }
                 .safeAreaInset(
                     edge: .bottom,
-                    spacing: 30
+                    spacing: 0
                 ) {
                     stickyDeleteButton
                 }
@@ -79,6 +114,11 @@ extension CGMSettings {
                             .tint(.red)
                     }
                 } message: { Text("Are you sure you want to delete \(cgmCurrent.displayName)?") }
+                .onAppear {
+                    if cgmCurrent.type == .simulator {
+                        initializeSimulatorSettings()
+                    }
+                }
             }
         }
 
@@ -95,7 +135,7 @@ extension CGMSettings {
                                     "To configure your CGM, tap the button below. In the form that opens, enter your Nightscout credentials to connect to your instance." :
                                     "Tap the button below to open your Nightscout instance in your iPhone's default browser."
                             ).font(.footnote)
-                                .foregroundColor(.secondary)
+                                .foregroundStyle(Color.secondary)
                                 .lineLimit(nil)
                                 .padding(.vertical)
                         }
@@ -136,61 +176,242 @@ extension CGMSettings {
             }
         }
 
-        var customCGMSection: some View {
+        var xDripConfigurationSection: some View {
             Section(
                 header: Text("Configuration"),
                 content: {
-                    if cgmCurrent.type == .xdrip {
-                        VStack(alignment: .leading) {
-                            if let cgmTransmitterDeviceAddress = UserDefaults.standard
-                                .cgmTransmitterDeviceAddress
-                            {
-                                Text("CGM address :").padding(.top)
-                                Text(cgmTransmitterDeviceAddress)
-                            } else {
-                                Text("CGM is not used as heartbeat.").padding(.top)
+                    VStack(alignment: .leading) {
+                        if let cgmTransmitterDeviceAddress = UserDefaults.standard
+                            .cgmTransmitterDeviceAddress
+                        {
+                            Text("CGM address :").padding(.top)
+                            Text(cgmTransmitterDeviceAddress)
+                        } else {
+                            Text("CGM is not used as heartbeat.").padding(.top)
+                        }
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "A heartbeat tells Trio to start a loop cycle. This is required for closed loop."
+                            )
+                            .font(.footnote)
+                            .foregroundStyle(Color.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                        }.padding(.vertical)
+
+                        if let link = cgmCurrent.type.externalLink {
+                            Button {
+                                UIApplication.shared.open(link, options: [:], completionHandler: nil)
+                            } label: {
+                                HStack {
+                                    Text("About this source")
+                                    Spacer()
+                                    Image(systemName: "chevron.right")
+                                }
                             }
+                            .frame(maxWidth: .infinity, alignment: .leading)
+                        }
+                    }
+                }
+            ).listRowBackground(Color.chart)
+        }
 
-                            HStack(alignment: .center) {
-                                Text(
-                                    "A heartbeat tells Trio to start a loop cycle. This is required for closed loop."
-                                )
-                                .font(.footnote)
-                                .foregroundColor(.secondary)
-                                .lineLimit(nil)
-                                Spacer()
-                            }.padding(.vertical)
+        var simulatorConfigurationSection: some View {
+            Group {
+                Section(
+                    header: Text("Configuration"),
+                    content: {
+                        VStack(alignment: .leading, spacing: 12) {
+                            Text("CGM is not used as heartbeat.").lineLimit(nil)
+                                .padding(.top)
+
+                            Text("Glucose trace WILL NOT be affected by any insulin or carb entries.").lineLimit(nil)
+                                .bold()
+                        }
+
+                        VStack(alignment: .leading, spacing: 8) {
+                            Text(
+                                "The simulator creates a wave-like pattern that mimics natural glucose fluctuations throughout the day."
+                            ).lineLimit(nil)
+
+                            Text("Configuration changes will take effect on the next glucose reading.")
+                                .padding(.bottom).lineLimit(nil)
+                        }.foregroundStyle(Color.secondary).font(.footnote)
+                    }
+                ).listRowBackground(Color.chart)
+
+                Section {
+                    VStack(alignment: .leading, spacing: 10) {
+                        Toggle(isOn: $produceStaleValues) {
+                            VStack(alignment: .leading) {
+                                Text("Produce Stale Values")
+                            }
+                        }
+                        .padding(.top)
+                        .onChange(of: produceStaleValues) { _, newValue in
+                            UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_ProduceStaleValues")
                         }
-                    } else if cgmCurrent.type == .simulator {
+
                         Text(
-                            "Trio's glucose simulator does not offer any configuration. Its use is strictly for demonstration purposes only."
+                            "When stale values are enabled, the simulator will repeatedly output the last generated glucose value."
                         )
+                        .font(.footnote)
+                        .foregroundStyle(Color.secondary)
+                        .lineLimit(nil)
+                        .padding(.bottom)
                     }
+                }.listRowBackground(Color.chart)
 
-                    if let link = cgmCurrent.type.externalLink {
-                        Button {
-                            UIApplication.shared.open(link, options: [:], completionHandler: nil)
-                        } label: {
+                if !produceStaleValues {
+                    Section {
+                        VStack(alignment: .leading, spacing: 10) {
                             HStack {
-                                Text("About this source")
+                                Text("Center Value:").bold()
+
                                 Spacer()
-                                Image(systemName: "chevron.right")
-                            }
+
+                                Text(state.units == .mgdL ? centerValue.description : centerValue.formattedAsMmolL).bold()
+
+                                Text(state.units.rawValue).foregroundStyle(Color.secondary)
+                            }.padding(.top)
+
+                            Slider(value: $centerValue, in: 80 ... 200, step: 1)
+                                .accentColor(.accentColor)
+                                .onChange(of: centerValue) { _, newValue in
+                                    UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_CenterValue")
+                                }
+                                .padding(.vertical)
+
+                            Text("The average glucose level around which values will oscillate.")
+                                .font(.footnote)
+                                .foregroundStyle(Color.secondary)
+                                .lineLimit(nil)
+                                .padding(.bottom)
                         }
-                        .frame(maxWidth: .infinity, alignment: .leading)
-                    }
+                    }.listRowBackground(Color.chart)
+
+                    Section {
+                        VStack(alignment: .leading, spacing: 10) {
+                            HStack {
+                                Text("Amplitude:").bold()
+
+                                Spacer()
+
+                                Text("±")
+                                Text(state.units == .mgdL ? amplitude.description : amplitude.formattedAsMmolL).bold()
+
+                                Text(state.units.rawValue).foregroundStyle(Color.secondary)
+                            }.padding(.top)
+
+                            Slider(value: $amplitude, in: 10 ... 100, step: 5)
+                                .accentColor(.accentColor)
+                                .onChange(of: amplitude) { _, newValue in
+                                    UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_Amplitude")
+                                }
+                                .padding(.vertical)
+
+                            Text(
+                                "Range: \(state.units == .mgdL ? (centerValue - amplitude).description : (centerValue - amplitude).formattedAsMmolL)–\(state.units == .mgdL ? (centerValue + amplitude).description : (centerValue + amplitude).formattedAsMmolL) \(state.units.rawValue)"
+                            )
+                            .bold()
+                            .font(.footnote)
+                            .foregroundStyle(Color.secondary)
+                            .lineLimit(nil)
+
+                            Text("The maximum deviation from the center value. Higher values create wider swings.")
+                                .font(.footnote)
+                                .foregroundStyle(Color.secondary)
+                                .lineLimit(nil)
+                                .padding(.bottom)
+                        }
+                    }.listRowBackground(Color.chart)
+
+                    Section {
+                        VStack(alignment: .leading, spacing: 10) {
+                            HStack {
+                                Text("Period:").bold()
+
+                                Spacer()
+
+                                Text(Int(period / 3600).description).bold()
+
+                                Text("hours").foregroundStyle(Color.secondary)
+                            }.padding(.top)
+
+                            Slider(value: $period, in: 3600 ... 21600, step: 1800)
+                                .accentColor(.accentColor)
+                                .onChange(of: period) { _, newValue in
+                                    UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_Period")
+                                }
+                                .padding(.vertical)
+
+                            Text("The time it takes to complete one full cycle from high to low and back to high.")
+                                .font(.footnote)
+                                .foregroundStyle(Color.secondary)
+                                .lineLimit(nil)
+                                .padding(.bottom)
+                        }
+                    }.listRowBackground(Color.chart)
+
+                    Section {
+                        VStack(alignment: .leading, spacing: 10) {
+                            HStack {
+                                Text("Noise:").bold()
+
+                                Spacer()
+
+                                Text("±")
+
+                                Text(state.units == .mgdL ? noiseAmplitude.description : noiseAmplitude.formattedAsMmolL).bold()
+
+                                Text(state.units.rawValue).foregroundStyle(Color.secondary)
+                            }.padding(.top)
+
+                            Slider(value: $noiseAmplitude, in: 0 ... 20, step: 1)
+                                .accentColor(.accentColor)
+                                .onChange(of: noiseAmplitude) { _, newValue in
+                                    UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_NoiseAmplitude")
+                                }
+                                .padding(.vertical)
+
+                            Text("Random variation added to each reading to simulate real-world sensor noise.")
+                                .font(.footnote)
+                                .foregroundStyle(Color.secondary)
+                                .lineLimit(nil)
+                                .padding(.bottom)
+                        }
+                    }.listRowBackground(Color.chart)
                 }
-            ).listRowBackground(Color.chart)
+
+                Section {
+                    Button(action: {
+                        centerValue = OscillatingGenerator.Defaults.centerValue
+                        amplitude = OscillatingGenerator.Defaults.amplitude
+                        period = OscillatingGenerator.Defaults.period
+                        noiseAmplitude = OscillatingGenerator.Defaults.noiseAmplitude
+                        produceStaleValues = OscillatingGenerator.Defaults.produceStaleValues
+                        saveSimulatorSettings()
+                    }, label: {
+                        Text("Reset to Defaults")
+
+                    })
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .tint(.white)
+                }.listRowBackground(Color.accentColor)
+
+            }.listSectionSpacing(sectionSpacing)
         }
 
         var stickyDeleteButton: some View {
             ZStack {
                 Rectangle()
-                    .frame(width: UIScreen.main.bounds.width, height: 65)
+                    .frame(width: UIScreen.main.bounds.width, height: 120)
                     .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white)
                     .background(.thinMaterial)
                     .opacity(0.8)
                     .clipShape(Rectangle())
+                    .padding(.bottom, -55)
 
                 Button(action: {
                     shouldDisplayDeletionConfirmation.toggle()