Browse Source

[Part 5 of 6] Remove JSON encoding for pump events

This commit removes the old:
* CoreData -> DTOs -> JSON string -> [PumpHistoryEvent]

pipeline and replaces it with a direct pipeline:
* CoreData -> [PumpHistoryEvent]

The result of the two pipelines should be identical, except for the
handling of the subtype tempType of temp basal events, which we
nullify if we get an unrecognized string vs crashing as the old
pipeline would. This change is safe as Trio only ever records
"absolute" types and the value is copied but never actually used by
the algorithm.
Sam King 2 days ago
parent
commit
183376164e

+ 50 - 149
Model/Helper/PumpEvent+helper.swift

@@ -134,189 +134,90 @@ extension NSPredicate {
     }
 }
 
-// Declare helper structs ("data transfer objects" = DTO) to utilize parsing a flattened pump history
-struct BolusDTO: Codable {
-    var id: String
-    var timestamp: String
-    var amount: Double
-    var isExternal: Bool
-    var isSMB: Bool
-    var duration: Int
-    var _type: String = EventType.bolus.rawValue
-}
-
-struct TempBasalDTO: Codable {
-    var id: String
-    var timestamp: String
-    var temp: String
-    var rate: Double
-    var _type: String = EventType.tempBasal.rawValue
-}
-
-struct TempBasalDurationDTO: Codable {
-    var id: String
-    var timestamp: String
-    var duration: Int
-    var _type: String = EventType.tempBasalDuration.rawValue
-
-    private enum CodingKeys: String, CodingKey {
-        case id
-        case timestamp
-        case duration = "duration (min)"
-        case _type
-    }
-}
-
-struct SuspendDTO: Codable {
-    var id: String
-    var timestamp: String
-    var _type: String = EventType.pumpSuspend.rawValue
-}
+// MARK: - Native mapping
 
-struct ResumeDTO: Codable {
-    var id: String
-    var timestamp: String
-    var _type: String = EventType.pumpResume.rawValue
-}
-
-struct RewindDTO: Codable {
-    var id: String
-    var timestamp: String
-    var _type: String = EventType.rewind.rawValue
-}
-
-struct PrimeDTO: Codable {
-    var id: String
-    var timestamp: String
-    var _type: String = EventType.prime.rawValue
-}
-
-// Mask distinct DTO subtypes with a common enum that conforms to Encodable
-enum PumpEventDTO: Encodable {
-    case bolus(BolusDTO)
-    case tempBasal(TempBasalDTO)
-    case tempBasalDuration(TempBasalDurationDTO)
-    case suspend(SuspendDTO)
-    case resume(ResumeDTO)
-    case rewind(RewindDTO)
-    case prime(PrimeDTO)
-
-    func encode(to encoder: Encoder) throws {
-        switch self {
-        case let .bolus(bolus):
-            try bolus.encode(to: encoder)
-        case let .tempBasal(tempBasal):
-            try tempBasal.encode(to: encoder)
-        case let .tempBasalDuration(tempBasalDuration):
-            try tempBasalDuration.encode(to: encoder)
-        case let .suspend(suspend):
-            try suspend.encode(to: encoder)
-        case let .resume(resume):
-            try resume.encode(to: encoder)
-        case let .rewind(rewind):
-            try rewind.encode(to: encoder)
-        case let .prime(prime):
-            try prime.encode(to: encoder)
-        }
-    }
-}
-
-// Extension with helper functions to map pump events to DTO objects via uniform masking enum
 extension PumpEventStored {
-    static let dateFormatter: ISO8601DateFormatter = {
-        let formatter = ISO8601DateFormatter()
-        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-        return formatter
-    }()
+    /// Converts a stored pump event into the `PumpHistoryEvent`s the oref algorithm can use.
+    /// A temp basal yields a duration entry followed by a rate entry.
+    func toPumpHistoryEvents() -> [PumpHistoryEvent] {
+        var events: [PumpHistoryEvent] = []
+        if let bolus = toBolusPumpHistoryEvent() { events.append(bolus) }
+        if let duration = toTempBasalDurationPumpHistoryEvent() { events.append(duration) }
+        if let tempBasal = toTempBasalPumpHistoryEvent() { events.append(tempBasal) }
+        if let suspend = toSuspendPumpHistoryEvent() { events.append(suspend) }
+        if let resume = toResumePumpHistoryEvent() { events.append(resume) }
+        if let rewind = toRewindPumpHistoryEvent() { events.append(rewind) }
+        if let prime = toPrimePumpHistoryEvent() { events.append(prime) }
+        return events
+    }
 
-    func toBolusDTOEnum() -> PumpEventDTO? {
+    private func toBolusPumpHistoryEvent() -> PumpHistoryEvent? {
         guard let timestamp = timestamp, let bolus = bolus, let amount = bolus.amount else {
             return nil
         }
-
-        let bolusDTO = BolusDTO(
+        return PumpHistoryEvent(
             id: id ?? UUID().uuidString,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp),
-            amount: amount.doubleValue,
-            isExternal: bolus.isExternal,
+            type: .bolus,
+            timestamp: timestamp,
+            amount: Decimal(algorithmValue: amount.doubleValue),
+            duration: 0,
             isSMB: bolus.isSMB,
-            duration: 0
+            isExternal: bolus.isExternal
         )
-        return .bolus(bolusDTO)
     }
 
-    func toTempBasalDTOEnum() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let tempBasal = tempBasal, let rate = tempBasal.rate else {
+    // The temp basal duration populates `durationMin`, not `duration`.
+    private func toTempBasalDurationPumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, let tempBasal = tempBasal else {
             return nil
         }
-
-        let tempBasalDTO = TempBasalDTO(
-            id: "_\(id)",
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp),
-            temp: tempBasal.tempType ?? "unknown",
-            rate: rate.doubleValue
+        return PumpHistoryEvent(
+            id: id,
+            type: .tempBasalDuration,
+            timestamp: timestamp,
+            durationMin: Int(tempBasal.duration)
         )
-        return .tempBasal(tempBasalDTO)
     }
 
-    func toTempBasalDurationDTOEnum() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let tempBasal = tempBasal else {
+    // The temp basal rate entry id is prefixed with "_". An unrecognized `tempType` maps to nil.
+    private func toTempBasalPumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, let tempBasal = tempBasal, let rate = tempBasal.rate else {
             return nil
         }
-
-        let tempBasalDurationDTO = TempBasalDurationDTO(
-            id: id,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp),
-            duration: Int(tempBasal.duration)
+        return PumpHistoryEvent(
+            id: "_\(id)",
+            type: .tempBasal,
+            timestamp: timestamp,
+            rate: Decimal(algorithmValue: rate.doubleValue),
+            temp: tempBasal.tempType.flatMap { Trio.TempType(rawValue: $0) }
         )
-        return .tempBasalDuration(tempBasalDurationDTO)
     }
 
-    func toPumpSuspendDTO() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let type = type, type == EventType.pumpSuspend.rawValue else {
+    private func toSuspendPumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, type == EventType.pumpSuspend.rawValue else {
             return nil
         }
-
-        let suspendDTO = SuspendDTO(
-            id: id,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp)
-        )
-        return .suspend(suspendDTO)
+        return PumpHistoryEvent(id: id, type: .pumpSuspend, timestamp: timestamp)
     }
 
-    func toPumpResumeDTO() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let type = type, type == EventType.pumpResume.rawValue else {
+    private func toResumePumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, type == EventType.pumpResume.rawValue else {
             return nil
         }
-
-        let resumeDTO = ResumeDTO(
-            id: id,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp)
-        )
-        return .resume(resumeDTO)
+        return PumpHistoryEvent(id: id, type: .pumpResume, timestamp: timestamp)
     }
 
-    func toRewindDTO() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let type = type, type == EventType.rewind.rawValue else {
+    private func toRewindPumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, type == EventType.rewind.rawValue else {
             return nil
         }
-
-        let rewindDTO = RewindDTO(
-            id: id,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp)
-        )
-        return .rewind(rewindDTO)
+        return PumpHistoryEvent(id: id, type: .rewind, timestamp: timestamp)
     }
 
-    func toPrimeDTO() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let type = type, type == EventType.prime.rawValue else {
+    private func toPrimePumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, type == EventType.prime.rawValue else {
             return nil
         }
-
-        let primeDTO = PrimeDTO(
-            id: id,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp)
-        )
-        return .prime(primeDTO)
+        return PumpHistoryEvent(id: id, type: .prime, timestamp: timestamp)
     }
 }

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -831,6 +831,7 @@
 		DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */; };
 		DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */; };
 		DDDD10A12F4E22C000F9C645 /* CarbsNativeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */; };
+		DDDD10B12F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD10B02F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift */; };
 		DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */; };
 		DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */; };
 		DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */; };
@@ -1844,6 +1845,7 @@
 		DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSmoothingTests.swift; sourceTree = "<group>"; };
 		DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseNativeConversionTests.swift; sourceTree = "<group>"; };
 		DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsNativeConversionTests.swift; sourceTree = "<group>"; };
+		DDDD10B02F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpHistoryNativeConversionTests.swift; sourceTree = "<group>"; };
 		DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTDDStorage.swift; sourceTree = "<group>"; };
 		DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
@@ -2980,6 +2982,7 @@
 				DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */,
 				DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */,
 				DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */,
+				DDDD10B02F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift */,
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
@@ -5645,6 +5648,7 @@
 				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
 				DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */,
 				DDDD10A12F4E22C000F9C645 /* CarbsNativeConversionTests.swift in Sources */,
+				DDDD10B12F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				CA02000000000000000010C2 /* DeliveryLimitsSyncTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,

+ 5 - 0
Trio/Sources/APS/Extensions/DecimalExtensions.swift

@@ -4,6 +4,11 @@ extension Decimal {
     func clamp(to pickerSetting: PickerSetting) -> Decimal {
         max(min(self, pickerSetting.max), pickerSetting.min)
     }
+
+    /// Converts a `Double` to a `Decimal` using JSON style conversion
+    init(algorithmValue value: Double) {
+        self = Decimal(string: value.description) ?? Decimal(value)
+    }
 }
 
 extension Collection where Element == Decimal {

+ 35 - 78
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -119,9 +119,9 @@ final class OpenAPS {
     private func parsePumpHistory(
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
         simulatedBolusAmount: Decimal? = nil
-    ) async throws -> String {
-        // Return an empty JSON object if the list of object IDs is empty
-        guard !pumpHistoryObjectIDs.isEmpty else { return "{}" }
+    ) async throws -> [PumpHistoryEvent] {
+        // Empty history returns an empty array, which also drops any simulated bolus.
+        guard !pumpHistoryObjectIDs.isEmpty else { return [] }
 
         // Addresses https://github.com/nightscout/Trio/issues/898
         //
@@ -133,87 +133,50 @@ final class OpenAPS {
 
         // Execute all operations on the background context
         return await context.perform {
-            // Load and map pump events to DTOs
-            var dtos = self.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes)
+            // Load and map pump events to native algorithm models
+            var events = OpenAPS.nativePumpHistory(
+                pumpHistoryObjectIDs,
+                orphanedResumes: orphanedResumes,
+                from: self.context
+            )
 
-            // Optionally add the IOB as a DTO
+            // Optionally add the simulated bolus for the bolus-preview simulation
             if let simulatedBolusAmount = simulatedBolusAmount {
-                let simulatedBolusDTO = self.createSimulatedBolusDTO(simulatedBolusAmount: simulatedBolusAmount)
-                dtos.insert(simulatedBolusDTO, at: 0)
+                events.insert(self.createSimulatedBolusEvent(simulatedBolusAmount: simulatedBolusAmount), at: 0)
             }
 
-            // Convert the DTOs to JSON
-            return self.jsonConverter.convertToJSON(dtos)
+            return events
         }
     }
 
-    private func loadAndMapPumpEvents(
-        _ pumpHistoryObjectIDs: [NSManagedObjectID],
-        orphanedResumes: [NSManagedObjectID]
-    ) -> [PumpEventDTO] {
-        OpenAPS.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes, from: context)
-    }
-
-    /// Fetches and parses pump events, expose this as static and not private for testing
-    static func loadAndMapPumpEvents(
+    /// Fetches and maps pump events into `[PumpHistoryEvent]`, expose this as static and not private for testing
+    static func nativePumpHistory(
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
         orphanedResumes: [NSManagedObjectID],
         from context: NSManagedObjectContext
-    ) -> [PumpEventDTO] {
+    ) -> [PumpHistoryEvent] {
         let orphanedSet = Set(orphanedResumes)
         let filteredObjectIds = pumpHistoryObjectIDs.filter { !orphanedSet.contains($0) }
-        // Load the pump events from the object IDs
         let pumpHistory: [PumpEventStored] = filteredObjectIds
             .compactMap { context.object(with: $0) as? PumpEventStored }
 
-        // Create the DTOs
-        let dtos: [PumpEventDTO] = pumpHistory.flatMap { event -> [PumpEventDTO] in
-            var eventDTOs: [PumpEventDTO] = []
-            if let bolusDTO = event.toBolusDTOEnum() {
-                eventDTOs.append(bolusDTO)
-            }
-            if let tempBasalDurationDTO = event.toTempBasalDurationDTOEnum() {
-                eventDTOs.append(tempBasalDurationDTO)
-            }
-            if let tempBasalDTO = event.toTempBasalDTOEnum() {
-                eventDTOs.append(tempBasalDTO)
-            }
-            if let pumpSuspendDTO = event.toPumpSuspendDTO() {
-                eventDTOs.append(pumpSuspendDTO)
-            }
-            if let pumpResumeDTO = event.toPumpResumeDTO() {
-                eventDTOs.append(pumpResumeDTO)
-            }
-            if let rewindDTO = event.toRewindDTO() {
-                eventDTOs.append(rewindDTO)
-            }
-            if let primeDTO = event.toPrimeDTO() {
-                eventDTOs.append(primeDTO)
-            }
-            return eventDTOs
-        }
-        return dtos
+        return pumpHistory.flatMap { $0.toPumpHistoryEvents() }
     }
 
-    private func createSimulatedBolusDTO(simulatedBolusAmount: Decimal) -> PumpEventDTO {
-        let oneSecondAgo = Calendar.current
-            .date(
-                byAdding: .second,
-                value: -1,
-                to: Date()
-            )! // adding -1s to the current Date ensures that oref actually uses the mock entry to calculate iob and not guard it away
-        let dateFormatted = PumpEventStored.dateFormatter.string(from: oneSecondAgo)
+    private func createSimulatedBolusEvent(simulatedBolusAmount: Decimal) -> PumpHistoryEvent {
+        // Adding -1s to the current Date ensures oref actually uses the mock entry to calculate iob
+        // and does not guard it away.
+        let oneSecondAgo = Date().addingTimeInterval(-1)
 
-        let bolusDTO = BolusDTO(
+        return PumpHistoryEvent(
             id: UUID().uuidString,
-            timestamp: dateFormatted,
-            amount: Double(simulatedBolusAmount),
-            isExternal: false,
-            isSMB: true,
+            type: .bolus,
+            timestamp: oneSecondAgo,
+            amount: simulatedBolusAmount,
             duration: 0,
-            _type: "Bolus"
+            isSMB: true,
+            isExternal: false
         )
-        return .bolus(bolusDTO)
     }
 
     /// Detects a cold-start orphaned resume: returns the resume's object ID if it's an orphaned resume
@@ -303,7 +266,7 @@ final class OpenAPS {
 
         // Await the results of asynchronous tasks
         let (
-            pumpHistoryJSON,
+            pumpHistory,
             carbs,
             glucose,
             trioCustomOrefVariables,
@@ -326,7 +289,7 @@ final class OpenAPS {
 
         // Meal calculation
         let meal = try self.meal(
-            pumphistory: pumpHistoryJSON,
+            pumphistory: pumpHistory,
             profile: profile,
             basalProfile: basalProfile,
             clock: clock,
@@ -336,7 +299,7 @@ final class OpenAPS {
 
         // IOB calculation
         let iob = try self.iob(
-            pumphistory: pumpHistoryJSON,
+            pumphistory: pumpHistory,
             profile: profile,
             clock: clock,
             autosens: autosens.isEmpty ? .null : autosens
@@ -363,7 +326,6 @@ final class OpenAPS {
             meal: meal,
             microBolusAllowed: true,
             reservoir: reservoir,
-            pumpHistory: pumpHistoryJSON,
             preferences: preferences,
             basalProfile: basalProfile,
             trioCustomOrefVariables: trioCustomOrefVariables
@@ -475,7 +437,7 @@ final class OpenAPS {
         async let getTempTargets = loadFileFromStorageAsync(name: Settings.tempTargets)
 
         // Await the results of asynchronous tasks
-        let (pumpHistoryJSON, carbs, glucose, profile, basalProfile, tempTargets) = await (
+        let (pumpHistory, carbs, glucose, profile, basalProfile, tempTargets) = await (
             try parsePumpHistory(await pumpHistoryObjectIDs),
             try carbsFetch,
             try glucoseFetch,
@@ -487,7 +449,7 @@ final class OpenAPS {
         // Autosense
         let autosenseResult = try autosense(
             glucose: glucose,
-            pumpHistory: pumpHistoryJSON,
+            pumpHistory: pumpHistory,
             basalprofile: basalProfile,
             profile: profile,
             carbs: carbs,
@@ -616,16 +578,13 @@ final class OpenAPS {
     }
 
     private func iob(
-        pumphistory: JSON,
+        pumphistory: [PumpHistoryEvent],
         profile: JSON,
         clock: JSON,
         autosens: JSON
     ) throws -> RawJSON {
         // FIXME: For now we'll just remove duplicate suspends here (ISSUE-399)
-        var pumphistory = pumphistory
-        if let pumpHistoryArray = try? JSONBridge.pumpHistory(from: pumphistory) {
-            pumphistory = pumpHistoryArray.removingDuplicateSuspendResumeEvents().rawJSON
-        }
+        let pumphistory = pumphistory.removingDuplicateSuspendResumeEvents()
 
         let swiftResult = OpenAPSSwift
             .iob(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
@@ -633,7 +592,7 @@ final class OpenAPS {
     }
 
     private func meal(
-        pumphistory: JSON,
+        pumphistory: [PumpHistoryEvent],
         profile: JSON,
         basalProfile: JSON,
         clock: JSON,
@@ -654,7 +613,7 @@ final class OpenAPS {
 
     private func autosense(
         glucose: [BloodGlucose],
-        pumpHistory: JSON,
+        pumpHistory: [PumpHistoryEvent],
         basalprofile: JSON,
         profile: JSON,
         carbs: [CarbsEntry],
@@ -682,7 +641,6 @@ final class OpenAPS {
         meal: JSON,
         microBolusAllowed: Bool,
         reservoir: JSON,
-        pumpHistory: JSON,
         preferences: JSON,
         basalProfile: JSON,
         trioCustomOrefVariables: JSON
@@ -697,7 +655,6 @@ final class OpenAPS {
             meal: meal,
             microBolusAllowed: microBolusAllowed,
             reservoir: reservoir,
-            pumpHistory: pumpHistory,
             preferences: preferences,
             basalProfile: basalProfile,
             trioCustomOrefVariables: trioCustomOrefVariables,

+ 0 - 19
Trio/Sources/APS/OpenAPSSwift/JSONBridge.swift

@@ -48,25 +48,6 @@ enum JSONBridge {
         try JSONBridge.from(string: from.rawJSON)
     }
 
-    static func pumpHistory(from: JSON) throws -> [PumpHistoryEvent] {
-        do {
-            return try JSONBridge.from(string: from.rawJSON)
-        } catch {
-            // see if we got an empty object "{}"
-            guard let data = from.rawJSON.data(using: .utf8) else {
-                throw error
-            }
-
-            if let parsedObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
-               parsedObject.isEmpty
-            {
-                return []
-            }
-
-            throw error
-        }
-    }
-
     static func profile(from: JSON) throws -> Profile {
         try JSONBridge.from(string: from.rawJSON)
     }

+ 5 - 10
Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift

@@ -34,7 +34,6 @@ struct OpenAPSSwift {
         meal: JSON,
         microBolusAllowed: Bool,
         reservoir: JSON,
-        pumpHistory: JSON,
         preferences: JSON,
         basalProfile: JSON,
         trioCustomOrefVariables: JSON,
@@ -48,7 +47,6 @@ struct OpenAPSSwift {
             let meal = try JSONBridge.computedCarbs(from: meal)
             let microBolusAllowed = microBolusAllowed
             let reservoir = Decimal(string: reservoir.rawJSON)
-            let pumpHistory = try JSONBridge.pumpHistory(from: pumpHistory)
             let preferences = try JSONBridge.preferences(from: preferences)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let trioCustomOrefVariables = try JSONBridge.trioCustomOrefVariables(from: trioCustomOrefVariables)
@@ -88,7 +86,7 @@ struct OpenAPSSwift {
     }
 
     static func meal(
-        pumphistory: JSON,
+        pumphistory: [PumpHistoryEvent],
         profile: JSON,
         basalProfile: JSON,
         clock: JSON,
@@ -96,13 +94,12 @@ struct OpenAPSSwift {
         glucose: [BloodGlucose]
     ) -> (OrefFunctionResult) {
         do {
-            let pumpHistory = try JSONBridge.pumpHistory(from: pumphistory)
             let profile = try JSONBridge.profile(from: profile)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let clock = try JSONBridge.clock(from: clock)
 
             let mealResult = try MealGenerator.generate(
-                pumpHistory: pumpHistory,
+                pumpHistory: pumphistory,
                 profile: profile,
                 basalProfile: basalProfile,
                 clock: clock,
@@ -116,15 +113,14 @@ struct OpenAPSSwift {
         }
     }
 
-    static func iob(pumphistory: JSON, profile: JSON, clock: JSON, autosens: JSON) -> (OrefFunctionResult) {
+    static func iob(pumphistory: [PumpHistoryEvent], profile: JSON, clock: JSON, autosens: JSON) -> (OrefFunctionResult) {
         do {
-            let pumpHistory = try JSONBridge.pumpHistory(from: pumphistory)
             let profile = try JSONBridge.profile(from: profile)
             let clock = try JSONBridge.clock(from: clock)
             let autosens = try JSONBridge.autosens(from: autosens)
 
             let iobResult = try IobGenerator.generate(
-                history: pumpHistory,
+                history: pumphistory,
                 profile: profile,
                 clock: clock,
                 autosens: autosens
@@ -138,7 +134,7 @@ struct OpenAPSSwift {
 
     static func autosense(
         glucose: [BloodGlucose],
-        pumpHistory: JSON,
+        pumpHistory: [PumpHistoryEvent],
         basalProfile: JSON,
         profile: JSON,
         carbs: [CarbsEntry],
@@ -147,7 +143,6 @@ struct OpenAPSSwift {
         includeDeviationsForTesting: Bool = false
     ) -> (OrefFunctionResult) {
         do {
-            let pumpHistory = try JSONBridge.pumpHistory(from: pumpHistory)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let profile = try JSONBridge.profile(from: profile)
             let tempTargets = try JSONBridge.tempTargets(from: tempTargets)

+ 3 - 8
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -371,11 +371,6 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         }
     }
 
-    /// Converts a `Double` to a `Decimal` using JSON style conversion
-    static func algorithmDecimal(_ value: Double) -> Decimal {
-        Decimal(string: value.description) ?? Decimal(value)
-    }
-
     /// Converts CoreData stored carb entries into a struct that the oref algorithm can use
     static func mapToCarbsEntry(_ carbEntry: CarbEntryStored) -> CarbsEntry {
         // The old encode used `date ?? Date()` for both created_at and actualDate.
@@ -384,9 +379,9 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             id: carbEntry.id?.uuidString,
             createdAt: date,
             actualDate: date,
-            carbs: algorithmDecimal(carbEntry.carbs),
-            fat: algorithmDecimal(carbEntry.fat),
-            protein: algorithmDecimal(carbEntry.protein),
+            carbs: Decimal(algorithmValue: carbEntry.carbs),
+            fat: Decimal(algorithmValue: carbEntry.fat),
+            protein: Decimal(algorithmValue: carbEntry.protein),
             note: nil,
             enteredBy: CarbsEntry.local,
             isFPU: carbEntry.isFPU,

+ 1 - 1
TrioTests/CarbsNativeConversionTests.swift

@@ -41,7 +41,7 @@ import Testing
     @Test("Fractional carbs/fat/protein keep clean decimal values") func testFractionalDecimals() async throws {
         // 33.33 as a Double is 33.32999999999999488; the old JSON round-trip recovered the clean
         // 33.33 (JSONEncoder writes the shortest round-trippable string). `Decimal(Double)` would
-        // leak the binary expansion, so `algorithmDecimal` must reproduce the clean value.
+        // leak the binary expansion, so `Decimal(algorithmValue:)` must reproduce the clean value.
         await insertCarb(carbs: 33.33, isFPU: true, date: fixedDate(minutesAgo: 0), fat: 5.5, protein: 3.2, id: uuid(1))
         await insertCarb(carbs: 12.5, isFPU: false, date: fixedDate(minutesAgo: 5), id: uuid(2))
 

+ 17 - 24
TrioTests/JSONImporterTests.swift

@@ -92,7 +92,7 @@ class BundleReference {}
         ) as? [PumpEventStored] ?? []
 
         let objectIds = allReadings.map(\.objectID)
-        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, orphanedResumes: [], from: context)
+        let parsedHistory = OpenAPS.nativePumpHistory(objectIds, orphanedResumes: [], from: context)
 
         var bolusTotal = 0.0
         var bolusCount = 0
@@ -103,21 +103,21 @@ class BundleReference {}
         var suspendCount = 0
         var resumeCount = 0
         for event in parsedHistory {
-            switch event {
-            case let .bolus(bolus):
-                bolusTotal += bolus.amount
+            switch event.type {
+            case .bolus:
+                bolusTotal += event.amount.map { ($0 as NSDecimalNumber).doubleValue } ?? 0
                 bolusCount += 1
-                if bolus.isSMB {
+                if event.isSMB == true {
                     smbCount += 1
                 }
-            case let .tempBasal(tempBasal):
-                rateTotal += tempBasal.rate
+            case .tempBasal:
+                rateTotal += event.rate.map { ($0 as NSDecimalNumber).doubleValue } ?? 0
                 tempBasalCount += 1
-            case let .tempBasalDuration(tempBasalDuration):
-                durationTotal += tempBasalDuration.duration
-            case .suspend:
+            case .tempBasalDuration:
+                durationTotal += event.durationMin ?? 0
+            case .pumpSuspend:
                 suspendCount += 1
-            case .resume:
+            case .pumpResume:
                 resumeCount += 1
             default:
                 fatalError("unhandled pump event")
@@ -172,22 +172,15 @@ class BundleReference {}
         ) as? [PumpEventStored] ?? []
 
         let objectIds = allReadings.map(\.objectID)
-        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, orphanedResumes: [], from: context)
+        let parsedHistory = OpenAPS.nativePumpHistory(objectIds, orphanedResumes: [], from: context)
 
         #expect(parsedHistory.count == 1)
 
-        let bolus: BolusDTO? = {
-            switch parsedHistory.first! {
-            case let .bolus(bolus):
-                return bolus
-            default:
-                return nil
-            }
-        }()
-
-        #expect(bolus != nil)
-        #expect(bolus!.isExternal)
-        #expect(bolus!.amount.isApproximatelyEqual(to: 0.88, epsilon: 0.01))
+        let bolus = parsedHistory.first
+        #expect(bolus?.type == .bolus)
+        #expect(bolus?.isExternal == true)
+        let amount = bolus?.amount.map { ($0 as NSDecimalNumber).doubleValue }
+        #expect(amount?.isApproximatelyEqual(to: 0.88, epsilon: 0.01) == true)
     }
 
     @Test("Import carb history with value checks") func testImportCarbHistoryDetails() async throws {

+ 316 - 0
TrioTests/PumpHistoryNativeConversionTests.swift

@@ -0,0 +1,316 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Trio
+
+/// Golden tests certifying that the native `PumpEventStored` → `[PumpHistoryEvent]` mapping
+/// (`OpenAPS.nativePumpHistory` / `PumpEventStored.toPumpHistoryEvents`) reproduces, field for
+/// field, the pump history the algorithm used to receive through the old JSON round-trip
+/// (`PumpEventStored` → `[PumpEventDTO]` → JSON → `JSONBridge.pumpHistory`) — with one deliberate
+/// change: a nil `tempType` becomes `temp = nil` instead of throwing during decode.
+@Suite("Pump History Native Conversion Tests", .serialized) struct PumpHistoryNativeConversionTests {
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
+
+    init() async throws {
+        coreDataStack = try await CoreDataStack.createForTests()
+        testContext = coreDataStack.newTaskContext()
+    }
+
+    // MARK: - Golden tests (native mapping vs frozen old-path output)
+
+    @Test("A bolus maps identically") func testBolus() async throws {
+        let ids = [
+            try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 2.5, isSMB: true, isExternal: false)
+        ]
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(
+                id: uuid(1),
+                type: .bolus,
+                timestamp: fixedDate(0),
+                amount: dec("2.5"),
+                duration: 0,
+                isSMB: true,
+                isExternal: false
+            )
+        ])
+    }
+
+    @Test("An external, non-SMB bolus maps identically") func testExternalBolus() async throws {
+        let ids = [
+            try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 0.88, isSMB: false, isExternal: true)
+        ]
+        // 0.88 stored as a Core Data Decimal round-trips through the lossy Double hop to
+        // 0.8800000000000001; the golden freezes that coerced value.
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(
+                id: uuid(1),
+                type: .bolus,
+                timestamp: fixedDate(0),
+                amount: dec("0.8800000000000001"),
+                duration: 0,
+                isSMB: false,
+                isExternal: true
+            )
+        ])
+    }
+
+    @Test("A temp basal emits a duration entry then a rate entry, both identical") func testTempBasal() async throws {
+        let ids = [
+            try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 0.85, durationMinutes: 30, tempType: "absolute")
+        ]
+        let native = try await nativeEvents(ids)
+        // Exactly two entries, ordered duration-then-rate.
+        #expect(native.count == 2)
+        #expect(native.first?.type == .tempBasalDuration)
+        #expect(native.first?.durationMin == 30)
+        #expect(native.first?.duration == nil, "the duration entry uses durationMin, never duration")
+        #expect(native.last?.type == .tempBasal)
+        #expect(native.last?.id == "_\(uuid(1))", "the rate entry id is prefixed with an underscore")
+        #expect(native.last?.temp == .absolute)
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30),
+            PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("0.85"), temp: .absolute)
+        ])
+    }
+
+    @Test("A percent temp basal maps identically") func testPercentTempBasal() async throws {
+        let ids = [
+            try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 1.5, durationMinutes: 45, tempType: "percent")
+        ]
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 45),
+            PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("1.5"), temp: .percent)
+        ])
+    }
+
+    @Test("A temp basal with a nil rate emits only the duration entry") func testTempBasalNilRate() async throws {
+        let ids = [
+            try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: nil, durationMinutes: 30, tempType: "absolute")
+        ]
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30)
+        ])
+    }
+
+    @Test("Suspend, resume, rewind and prime map identically") func testStatusEvents() async throws {
+        let ids = [
+            try await insertStatusEvent(id: uuid(1), date: fixedDate(0), type: .pumpSuspend),
+            try await insertStatusEvent(id: uuid(2), date: fixedDate(1), type: .pumpResume),
+            try await insertStatusEvent(id: uuid(3), date: fixedDate(2), type: .rewind),
+            try await insertStatusEvent(id: uuid(4), date: fixedDate(3), type: .prime)
+        ]
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(id: uuid(1), type: .pumpSuspend, timestamp: fixedDate(0)),
+            PumpHistoryEvent(id: uuid(2), type: .pumpResume, timestamp: fixedDate(1)),
+            PumpHistoryEvent(id: uuid(3), type: .rewind, timestamp: fixedDate(2)),
+            PumpHistoryEvent(id: uuid(4), type: .prime, timestamp: fixedDate(3))
+        ])
+    }
+
+    @Test("A mixed sequence maps identically") func testMixedSequence() async throws {
+        let ids = [
+            try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 1.0, isSMB: true, isExternal: false),
+            try await insertTempBasal(id: uuid(2), date: fixedDate(1), rate: 0.7, durationMinutes: 30, tempType: "absolute"),
+            try await insertStatusEvent(id: uuid(3), date: fixedDate(2), type: .pumpSuspend),
+            try await insertStatusEvent(id: uuid(4), date: fixedDate(3), type: .pumpResume),
+            try await insertBolus(id: uuid(5), date: fixedDate(4), amount: 0.05, isSMB: true, isExternal: false)
+        ]
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(
+                id: uuid(1),
+                type: .bolus,
+                timestamp: fixedDate(0),
+                amount: dec("1.0"),
+                duration: 0,
+                isSMB: true,
+                isExternal: false
+            ),
+            PumpHistoryEvent(id: uuid(2), type: .tempBasalDuration, timestamp: fixedDate(1), durationMin: 30),
+            PumpHistoryEvent(id: "_\(uuid(2))", type: .tempBasal, timestamp: fixedDate(1), rate: dec("0.7"), temp: .absolute),
+            PumpHistoryEvent(id: uuid(3), type: .pumpSuspend, timestamp: fixedDate(2)),
+            PumpHistoryEvent(id: uuid(4), type: .pumpResume, timestamp: fixedDate(3)),
+            PumpHistoryEvent(
+                id: uuid(5),
+                type: .bolus,
+                timestamp: fixedDate(4),
+                amount: dec("0.05"),
+                duration: 0,
+                isSMB: true,
+                isExternal: false
+            )
+        ])
+    }
+
+    @Test("Orphaned resumes are filtered") func testOrphanedResumeFiltering() async throws {
+        let bolus = try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 1.0, isSMB: true, isExternal: false)
+        let orphanedResume = try await insertStatusEvent(id: uuid(2), date: fixedDate(1), type: .pumpResume)
+        let ids = [bolus, orphanedResume]
+
+        try await assertNativeMatchesGolden(ids, orphanedResumes: [orphanedResume], golden: [
+            PumpHistoryEvent(
+                id: uuid(1),
+                type: .bolus,
+                timestamp: fixedDate(0),
+                amount: dec("1.0"),
+                duration: 0,
+                isSMB: true,
+                isExternal: false
+            )
+        ])
+    }
+
+    @Test("Empty history maps to an empty array") func testEmpty() async throws {
+        try await assertNativeMatchesGolden([], golden: [])
+    }
+
+    // MARK: - The one deliberate behavioral change
+
+    @Test("A nil tempType becomes temp = nil instead of throwing") func testNilTempTypeIsRobust() async throws {
+        let ids = [
+            try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 0.85, durationMinutes: 30, tempType: nil)
+        ]
+        // The old JSON path emitted the string "unknown" for a nil tempType, which threw while
+        // decoding. Native emits both entries and leaves temp nil instead.
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30),
+            PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("0.85"), temp: nil)
+        ])
+    }
+
+    // MARK: - Comparison helpers
+
+    /// Asserts the native mapping reproduces the frozen golden `[PumpHistoryEvent]`.
+    private func assertNativeMatchesGolden(
+        _ objectIDs: [NSManagedObjectID],
+        orphanedResumes: [NSManagedObjectID] = [],
+        golden: [PumpHistoryEvent]
+    ) async throws {
+        let native = try await nativeEvents(objectIDs, orphanedResumes: orphanedResumes)
+
+        #expect(native.count == golden.count, "native produced \(native.count) events, golden has \(golden.count)")
+
+        for (index, pair) in zip(native, golden).enumerated() {
+            expectFieldsEqual(pair.0, pair.1, event: index)
+        }
+    }
+
+    private func nativeEvents(
+        _ objectIDs: [NSManagedObjectID],
+        orphanedResumes: [NSManagedObjectID] = []
+    ) async throws -> [PumpHistoryEvent] {
+        try await testContext.perform {
+            OpenAPS.nativePumpHistory(objectIDs, orphanedResumes: orphanedResumes, from: self.testContext)
+        }
+    }
+
+    /// Field-by-field comparison, comparing `timestamp` at millisecond resolution.
+    private func expectFieldsEqual(_ actual: PumpHistoryEvent, _ expected: PumpHistoryEvent, event index: Int) {
+        #expect(actual.id == expected.id, "event \(index): id \(actual.id) != \(expected.id)")
+        #expect(actual.type == expected.type, "event \(index): type \(actual.type) != \(expected.type)")
+        #expect(
+            Self.millisecondString(actual.timestamp) == Self.millisecondString(expected.timestamp),
+            "event \(index): timestamp mismatch"
+        )
+        #expect(actual.amount == expected.amount, "event \(index): amount \(desc(actual.amount)) != \(desc(expected.amount))")
+        #expect(
+            actual.duration == expected.duration,
+            "event \(index): duration \(desc(actual.duration)) != \(desc(expected.duration))"
+        )
+        #expect(
+            actual.durationMin == expected.durationMin,
+            "event \(index): durationMin \(desc(actual.durationMin)) != \(desc(expected.durationMin))"
+        )
+        #expect(actual.rate == expected.rate, "event \(index): rate \(desc(actual.rate)) != \(desc(expected.rate))")
+        #expect(actual.temp == expected.temp, "event \(index): temp \(desc(actual.temp)) != \(desc(expected.temp))")
+        #expect(actual.isSMB == expected.isSMB, "event \(index): isSMB \(desc(actual.isSMB)) != \(desc(expected.isSMB))")
+        #expect(
+            actual.isExternal == expected.isExternal,
+            "event \(index): isExternal \(desc(actual.isExternal)) != \(desc(expected.isExternal))"
+        )
+    }
+
+    private func desc<T>(_ value: T?) -> String { value.map { "\($0)" } ?? "nil" }
+
+    /// Builds a `Decimal` from its string form, matching `Decimal(algorithmValue:)`.
+    private func dec(_ string: String) -> Decimal { Decimal(string: string) ?? .zero }
+
+    private static func millisecondString(_ date: Date) -> String {
+        Formatter.iso8601withFractionalSeconds.string(from: date)
+    }
+
+    // MARK: - Fixture helpers
+
+    private func insertBolus(
+        id: String,
+        date: Date,
+        amount: Double,
+        isSMB: Bool,
+        isExternal: Bool
+    ) async throws -> NSManagedObjectID {
+        try await testContext.perform {
+            let event = PumpEventStored(context: self.testContext)
+            event.id = id
+            event.timestamp = date
+            event.type = PumpEventStored.EventType.bolus.rawValue
+
+            let bolus = BolusStored(context: self.testContext)
+            bolus.amount = NSDecimalNumber(value: amount)
+            bolus.isSMB = isSMB
+            bolus.isExternal = isExternal
+            bolus.pumpEvent = event
+
+            try self.testContext.save()
+            return event.objectID
+        }
+    }
+
+    private func insertTempBasal(
+        id: String,
+        date: Date,
+        rate: Double?,
+        durationMinutes: Int,
+        tempType: String?
+    ) async throws -> NSManagedObjectID {
+        try await testContext.perform {
+            let event = PumpEventStored(context: self.testContext)
+            event.id = id
+            event.timestamp = date
+            event.type = PumpEventStored.EventType.tempBasal.rawValue
+
+            let tempBasal = TempBasalStored(context: self.testContext)
+            tempBasal.rate = rate.map { NSDecimalNumber(value: $0) }
+            tempBasal.duration = Int16(durationMinutes)
+            tempBasal.tempType = tempType
+            tempBasal.pumpEvent = event
+
+            try self.testContext.save()
+            return event.objectID
+        }
+    }
+
+    private func insertStatusEvent(
+        id: String,
+        date: Date,
+        type: PumpEventStored.EventType
+    ) async throws -> NSManagedObjectID {
+        try await testContext.perform {
+            let event = PumpEventStored(context: self.testContext)
+            event.id = id
+            event.timestamp = date
+            event.type = type.rawValue
+            try self.testContext.save()
+            return event.objectID
+        }
+    }
+
+    /// A fixed base timestamp on whole seconds so fixtures are deterministic.
+    private func fixedDate(_ minutesAgo: Double) -> Date {
+        Date(timeIntervalSince1970: 1_700_000_000 - minutesAgo * 60)
+    }
+
+    private func uuid(_ n: Int) -> String {
+        String(format: "00000000-0000-0000-0000-%012d", n)
+    }
+}

+ 25 - 0
scripts/run_unit_tests.sh

@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+#
+# Build and run the Trio unit tests, mirroring the CI workflow (.github unit_tests.yml).
+#
+# Usage:
+#   scripts/run_unit_tests.sh                                  # run every test
+#   scripts/run_unit_tests.sh PumpHistoryNativeConversionTests # run a single suite/class
+#
+# The optional first argument is a test class/@Suite name in TrioTests.
+#
+# See installed runtimes with:  xcrun simctl list runtimes | grep iOS
+set -euo pipefail
+
+# Run from the repo root regardless of the caller's working directory.
+cd "$(dirname "$0")/.."
+
+DEST='platform=iOS Simulator,name=iPhone 17'
+ONLY="${1:+-only-testing:TrioTests/$1}"
+
+xcodebuild test \
+  -workspace Trio.xcworkspace \
+  -scheme "Trio Tests" \
+  -destination "$DEST" \
+  $ONLY \
+  2>&1 | tee xcodebuild.log