1 contributor
425 lines | 10.884kb

Charge Curve Storage Operation

Stocarea şi retrieval-ul curbelor de încărcare în persistență.

Responsabilități

  • Persistență curbe în Core Data (sesiuni + measurements)
  • Sincronizare curbe în iCloud (CloudKit)
  • Compresie date (sampling, agregare)
  • Cleanup și archiving sesiuni vechi

Arhitectură

Core Data entities

ChargeSession
├── id: UUID
├── chargedDeviceID: UUID
├── startTime: Date
├── endTime: Date
├── startBatteryPercent: Double?
├── endBatteryPercent: Double?
├── totalEnergyWh: Double
└── checkpoints: [ChargeCheckpoint]

ChargeCheckpoint (measurement)
├── id: UUID
├── sessionID: UUID
├── timestamp: Date
├── voltage: Double
├── current: Double
├── power: Double
├── temperature: Double?
└── batteryPercent: Double?

Storage layers

  1. In-memory cache: Recent sessions (last 24h)
  2. Local Core Data: All sessions
  3. iCloud CloudKit: Synced sessions (optional, per user)
  4. Archive: Old sessions (JSON export, local only)
Memory cache (24h)
       ↓
   Core Data (local)
   /           \
  ↓             ↓
 iCloud      Archive (JSON)
(CloudKit)   (5+ years old)

Invarianţi

  • MUST: Fiecare ChargeSession are id unic
  • MUST: Fiecare ChargeCheckpoint are reference valid la sessionID
  • MUST: Checkpoints ordonate cronologic în sesiune
  • MUST: startTime < endTime pentru completed sessions
  • MUST: CloudKit sync preserve integrity (no partial sessions)
  • SHOULD: Sesiuni < 5 minute nu se sincronizează (noise)
  • MAY: Archive sesiuni > 5 ani local-only

Save flow

1. In-memory accumulation

let session = meter.startChargeRecord(for: device)
// session object in-memory
// accumulate measurements în session.measurements array

while isCharging {
    let measurement = meter.lastDataPoint
    session.addMeasurement(measurement)  // In-memory
}

Properties: - Rapid (no I/O) - Loss on app crash - Limits: max 1MB per session (~10k measurements @ 1Hz)

2. Flush to Core Data

func endChargeRecord(_ session: ChargeRecord) {
    // 1. Isolate valid curve
    let isolated = isolateChargeRange(session.measurements)
    
    // 2. Save to Core Data
    let coreDataSession = ChargeSession()
    coreDataSession.id = session.id
    coreDataSession.chargedDeviceID = device.id
    coreDataSession.startTime = isolated.realStartTime
    coreDataSession.endTime = isolated.realEndTime
    coreDataSession.totalEnergyWh = calculateEnergy(isolated.measurements)
    
    // 3. Save checkpoints
    for measurement in isolated.measurements {
        let checkpoint = ChargeCheckpoint()
        checkpoint.timestamp = measurement.timestamp
        checkpoint.voltage = measurement.voltage
        checkpoint.current = measurement.current
        checkpoint.power = measurement.power
        // ... other fields
        coreDataSession.checkpoints.append(checkpoint)
    }
    
    // 4. Persist
    managedObjectContext.insert(coreDataSession)
    try? managedObjectContext.save()
}

Guarantees: - ACID transaction - Survives app crash - Instantly queryable

3. CloudKit sync (async)

// NSPersistentCloudKitContainer handles automatically
// New/modified ChargeSession → CloudKit
// Happens in background, doesn't block UI

Flow: Core Data save ↓ NSPersistentCloudKitContainer observes change ↓ Serializes to CloudKit record ↓ Uploads in background (when network available) ↓ Notifies on success/failure

CloudSync constraints: - MUST: Sessions < 5 min nu se syncă (noise filter) - MUST: Checkpoints limitate la max 1000 per CloudKit record - SHOULD: Aggregate samples dacă > 1000 checkpoints

Load flow

1. Query Core Data

func loadSessions(for device: ChargedDevice) -> [ChargeSession] {
    let request: NSFetchRequest<ChargeSession> = ChargeSession.fetchRequest()
    request.predicate = NSPredicate(format: "chargedDeviceID == %@", device.id as NSUUID)
    request.sortDescriptors = [NSSortDescriptor(key: "startTime", ascending: false)]
    
    let sessions = try? managedObjectContext.fetch(request)
    return sessions ?? []
}

func loadCheckpoints(for session: ChargeSession) -> [ChargeCheckpoint] {
    let request: NSFetchRequest<ChargeCheckpoint> = ChargeCheckpoint.fetchRequest()
    request.predicate = NSPredicate(format: "sessionID == %@", session.id as NSUUID)
    request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
    
    let checkpoints = try? managedObjectContext.fetch(request)
    return checkpoints ?? []
}

Performance: - O(log n) for device lookup (indexed) - O(k) for checkpoint load (k = checkpoint count, typically 1000-5000)

2. Reconstruct curve

func reconstructCurve(from session: ChargeSession) -> ChargeCurve {
    let checkpoints = loadCheckpoints(for: session)
    
    return ChargeCurve(
        startTime: session.startTime,
        endTime: session.endTime,
        totalEnergy: session.totalEnergyWh,
        measurements: checkpoints.map { cp in
            Measurement(
                timestamp: cp.timestamp,
                voltage: cp.voltage,
                current: cp.current,
                power: cp.power
            )
        }
    )
}

3. Cache in memory

// Keep last 24h in cache
var recentCurves: [UUID: ChargeCurve] = [:]

func cachedCurve(for sessionID: UUID) -> ChargeCurve? {
    if let cached = recentCurves[sessionID] {
        return cached  // Memory hit
    }
    
    // Core Data miss → fetch + cache
    guard let coreDataSession = loadSessionFromCoreData(sessionID) else {
        return nil
    }
    
    let curve = reconstructCurve(from: coreDataSession)
    recentCurves[sessionID] = curve
    return curve
}

Compression strategies

For frequent access (last 7 days)

Store: All 1Hz measurements Cost: ~1000 checkpoints per session

For occasional access (7-365 days)

Strategy: Downsample to 10Hz swift func downsampleCurve(measurements: [Measurement], factor: Int) -> [Measurement] { return measurements.enumerated() .filter { $0.offset % factor == 0 } .map { $0.element } }

Reduction: 10x fewer points Loss: Minimal (still captures curve shape) Cost: ~100 checkpoints per session

For archive (> 1 year)

Strategy: Store metadata only swift struct SessionMetadata { let id: UUID let chargedDeviceID: UUID let startTime: Date let endTime: Date let totalEnergyWh: Double let peakPowerW: Double let averagePowerW: Double // No measurements stored }

Cost: Minimal (constant per session) Retrieval: Summary only, not full curve

CloudKit constraints

Record size limits

  • CloudKit record: max 4 MB
  • Checkpoints JSON: ~100 bytes per checkpoint
  • Max checkpoints per record: ~40k
  • Typical session: 1000-5000 checkpoints

Sync strategy

func syncSessionsToCloudKit() {
    let allSessions = loadSessionsFromCoreData()
    
    for session in allSessions {
        // Filter: only sessions > 5 minutes
        guard session.duration >= 300 else { continue }
        
        // Aggregate: if checkpoints > 2000, downsample
        var checkpoints = loadCheckpoints(for: session)
        if checkpoints.count > 2000 {
            checkpoints = downsampleCurve(checkpoints, factor: 2)
        }
        
        // Push to CloudKit (NSPersistentCloudKitContainer handles)
    }
}

MUST: Filtrare sesiuni mici (< 5 min) SHOULD: Agregate checkpoint-uri dacă > 2000 MAY: Archive > 1 an local-only

API Public

Save

// Save completed session
func saveChargeSession(_ session: ChargeRecord)
// Isolates curve → persists to Core Data → CloudKit async

// Append checkpoint
func appendCheckpoint(_ measurement: Measurement, to sessionID: UUID)
// In-memory only, flushed on session end

Load

// Query by device
func loadSessions(for device: ChargedDevice) -> [ChargeSession]

// Load single session
func loadSession(id: UUID) -> ChargeSession?

// Load curve with checkpoints
func loadFullCurve(sessionID: UUID) -> ChargeCurve?

// Load metadata only (fast)
func loadSessionMetadata(sessionID: UUID) -> SessionMetadata?

Cleanup

// Archive old sessions (local)
func archiveOldSessions(olderThan: Date) -> Int
// Returns count archived

// Cleanup from iCloud
func deleteFromCloudKit(sessionID: UUID)
// MUST: permanent (CloudKit deletion, can't undo)

Comportamente critice

Concurrent saves

User: Start session on meter A
App: Saves session A
User: Also start session on meter B
App: Saves session B
⟹ Both saved (transactions independent)

MUST: Core Data transactions isolate (ACID) MUST: CloudKit syncs maintain order

CloudKit unavailable

Device offline
App saves to Core Data ✓
CloudKit sync queued
User goes online
Sync resumes automatically
⟹ Transparent

MUST: Queue pending changes SHOULD: Retry with backoff MUST: Persist queue to disk (survive app restart)

Duplicate on restore

Device A: 100 sessions synced to iCloud
User: Restore from backup
Device B: Pulls 100 sessions
Device A: Restarts, sees 100 sessions (already synced)
⟹ Deduplication needed

MUST: Deduplicate by session ID SHOULD: Keep most recent version

Testare

Unit tests

test_saveSession_PersistsToCoreData()
test_loadSessions_ByDevice()
test_loadCheckpoints_Ordered()
test_downsampleCurve_ReducesFactor()
test_cloudSyncFilters_SessionsUnder5Min()
test_archiveOldSessions_By Date()
test_deduplicateOnRestore()

Integration tests

  • [ ] Save session → reload → same data
  • [ ] CloudKit sync without network (queued)
  • [ ] Resume CloudKit sync when online
  • [ ] Downsample 7+ day old sessions
  • [ ] Archive 1+ year old sessions
  • [ ] Delete from CloudKit (permanent)

Performance considerations

Operation Time Notes
Save session < 100ms Core Data write
Load 100 sessions < 50ms Indexed query
Load curve (1000 pts) < 200ms Reconstruct array
CloudKit sync ~1-5s Network dependent
Downsample 5000 pts < 20ms CPU bound

Dependenţe

References

  • Charge Session Integrity
  • Core Data Performance Tuning: https://developer.apple.com/videos/play/wwdc2021/10190/
  • CloudKit limits: https://developer.apple.com/icloud/cloudkit/