Newer Older
425 lines | 10.884kb
Bogdan Timofte authored 2 weeks ago
1
# Charge Curve Storage Operation
2

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

            
5
## Responsabilități
6

            
7
- Persistență curbe în Core Data (sesiuni + measurements)
8
- Sincronizare curbe în iCloud (CloudKit)
9
- Compresie date (sampling, agregare)
10
- Cleanup și archiving sesiuni vechi
11

            
12
## Arhitectură
13

            
14
### Core Data entities
15

            
16
```
17
ChargeSession
18
├── id: UUID
19
├── chargedDeviceID: UUID
20
├── startTime: Date
21
├── endTime: Date
22
├── startBatteryPercent: Double?
23
├── endBatteryPercent: Double?
24
├── totalEnergyWh: Double
25
└── checkpoints: [ChargeCheckpoint]
26

            
27
ChargeCheckpoint (measurement)
28
├── id: UUID
29
├── sessionID: UUID
30
├── timestamp: Date
31
├── voltage: Double
32
├── current: Double
33
├── power: Double
34
├── temperature: Double?
35
└── batteryPercent: Double?
36
```
37

            
38
### Storage layers
39

            
40
1. **In-memory cache**: Recent sessions (last 24h)
41
2. **Local Core Data**: All sessions
42
3. **iCloud CloudKit**: Synced sessions (optional, per user)
43
4. **Archive**: Old sessions (JSON export, local only)
44

            
45
```
46
Memory cache (24h)
47
       ↓
48
   Core Data (local)
49
   /           \
50
  ↓             ↓
51
 iCloud      Archive (JSON)
52
(CloudKit)   (5+ years old)
53
```
54

            
55
## Invarianţi
56

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

            
65
## Save flow
66

            
67
### 1. In-memory accumulation
68

            
69
```swift
70
let session = meter.startChargeRecord(for: device)
71
// session object in-memory
72
// accumulate measurements în session.measurements array
73

            
74
while isCharging {
75
    let measurement = meter.lastDataPoint
76
    session.addMeasurement(measurement)  // In-memory
77
}
78
```
79

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

            
85
### 2. Flush to Core Data
86

            
87
```swift
88
func endChargeRecord(_ session: ChargeRecord) {
89
    // 1. Isolate valid curve
90
    let isolated = isolateChargeRange(session.measurements)
91

            
92
    // 2. Save to Core Data
93
    let coreDataSession = ChargeSession()
94
    coreDataSession.id = session.id
95
    coreDataSession.chargedDeviceID = device.id
96
    coreDataSession.startTime = isolated.realStartTime
97
    coreDataSession.endTime = isolated.realEndTime
98
    coreDataSession.totalEnergyWh = calculateEnergy(isolated.measurements)
99

            
100
    // 3. Save checkpoints
101
    for measurement in isolated.measurements {
102
        let checkpoint = ChargeCheckpoint()
103
        checkpoint.timestamp = measurement.timestamp
104
        checkpoint.voltage = measurement.voltage
105
        checkpoint.current = measurement.current
106
        checkpoint.power = measurement.power
107
        // ... other fields
108
        coreDataSession.checkpoints.append(checkpoint)
109
    }
110

            
111
    // 4. Persist
112
    managedObjectContext.insert(coreDataSession)
113
    try? managedObjectContext.save()
114
}
115
```
116

            
117
**Guarantees:**
118
- ACID transaction
119
- Survives app crash
120
- Instantly queryable
121

            
122
### 3. CloudKit sync (async)
123

            
124
```swift
125
// NSPersistentCloudKitContainer handles automatically
126
// New/modified ChargeSession → CloudKit
127
// Happens in background, doesn't block UI
128
```
129

            
130
**Flow:**
131
```
132
Core Data save
133
  ↓
134
NSPersistentCloudKitContainer observes change
135
  ↓
136
Serializes to CloudKit record
137
  ↓
138
Uploads in background (when network available)
139
  ↓
140
Notifies on success/failure
141
```
142

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

            
148
## Load flow
149

            
150
### 1. Query Core Data
151

            
152
```swift
153
func loadSessions(for device: ChargedDevice) -> [ChargeSession] {
154
    let request: NSFetchRequest<ChargeSession> = ChargeSession.fetchRequest()
155
    request.predicate = NSPredicate(format: "chargedDeviceID == %@", device.id as NSUUID)
156
    request.sortDescriptors = [NSSortDescriptor(key: "startTime", ascending: false)]
157

            
158
    let sessions = try? managedObjectContext.fetch(request)
159
    return sessions ?? []
160
}
161

            
162
func loadCheckpoints(for session: ChargeSession) -> [ChargeCheckpoint] {
163
    let request: NSFetchRequest<ChargeCheckpoint> = ChargeCheckpoint.fetchRequest()
164
    request.predicate = NSPredicate(format: "sessionID == %@", session.id as NSUUID)
165
    request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
166

            
167
    let checkpoints = try? managedObjectContext.fetch(request)
168
    return checkpoints ?? []
169
}
170
```
171

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

            
176
### 2. Reconstruct curve
177

            
178
```swift
179
func reconstructCurve(from session: ChargeSession) -> ChargeCurve {
180
    let checkpoints = loadCheckpoints(for: session)
181

            
182
    return ChargeCurve(
183
        startTime: session.startTime,
184
        endTime: session.endTime,
185
        totalEnergy: session.totalEnergyWh,
186
        measurements: checkpoints.map { cp in
187
            Measurement(
188
                timestamp: cp.timestamp,
189
                voltage: cp.voltage,
190
                current: cp.current,
191
                power: cp.power
192
            )
193
        }
194
    )
195
}
196
```
197

            
198
### 3. Cache in memory
199

            
200
```swift
201
// Keep last 24h in cache
202
var recentCurves: [UUID: ChargeCurve] = [:]
203

            
204
func cachedCurve(for sessionID: UUID) -> ChargeCurve? {
205
    if let cached = recentCurves[sessionID] {
206
        return cached  // Memory hit
207
    }
208

            
209
    // Core Data miss → fetch + cache
210
    guard let coreDataSession = loadSessionFromCoreData(sessionID) else {
211
        return nil
212
    }
213

            
214
    let curve = reconstructCurve(from: coreDataSession)
215
    recentCurves[sessionID] = curve
216
    return curve
217
}
218
```
219

            
220
## Compression strategies
221

            
222
### For frequent access (last 7 days)
223

            
224
**Store:** All 1Hz measurements
225
**Cost:** ~1000 checkpoints per session
226

            
227
### For occasional access (7-365 days)
228

            
229
**Strategy:** Downsample to 10Hz
230
```swift
231
func downsampleCurve(measurements: [Measurement], factor: Int) -> [Measurement] {
232
    return measurements.enumerated()
233
        .filter { $0.offset % factor == 0 }
234
        .map { $0.element }
235
}
236
```
237

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

            
242
### For archive (> 1 year)
243

            
244
**Strategy:** Store metadata only
245
```swift
246
struct SessionMetadata {
247
    let id: UUID
248
    let chargedDeviceID: UUID
249
    let startTime: Date
250
    let endTime: Date
251
    let totalEnergyWh: Double
252
    let peakPowerW: Double
253
    let averagePowerW: Double
254
    // No measurements stored
255
}
256
```
257

            
258
**Cost:** Minimal (constant per session)
259
**Retrieval:** Summary only, not full curve
260

            
261
## CloudKit constraints
262

            
263
### Record size limits
264

            
265
- CloudKit record: max 4 MB
266
- Checkpoints JSON: ~100 bytes per checkpoint
267
- Max checkpoints per record: ~40k
268
- Typical session: 1000-5000 checkpoints
269

            
270
### Sync strategy
271

            
272
```swift
273
func syncSessionsToCloudKit() {
274
    let allSessions = loadSessionsFromCoreData()
275

            
276
    for session in allSessions {
277
        // Filter: only sessions > 5 minutes
278
        guard session.duration >= 300 else { continue }
279

            
280
        // Aggregate: if checkpoints > 2000, downsample
281
        var checkpoints = loadCheckpoints(for: session)
282
        if checkpoints.count > 2000 {
283
            checkpoints = downsampleCurve(checkpoints, factor: 2)
284
        }
285

            
286
        // Push to CloudKit (NSPersistentCloudKitContainer handles)
287
    }
288
}
289
```
290

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

            
295
## API Public
296

            
297
### Save
298

            
299
```swift
300
// Save completed session
301
func saveChargeSession(_ session: ChargeRecord)
302
// Isolates curve → persists to Core Data → CloudKit async
303

            
304
// Append checkpoint
305
func appendCheckpoint(_ measurement: Measurement, to sessionID: UUID)
306
// In-memory only, flushed on session end
307
```
308

            
309
### Load
310

            
311
```swift
312
// Query by device
313
func loadSessions(for device: ChargedDevice) -> [ChargeSession]
314

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

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

            
321
// Load metadata only (fast)
322
func loadSessionMetadata(sessionID: UUID) -> SessionMetadata?
323
```
324

            
325
### Cleanup
326

            
327
```swift
328
// Archive old sessions (local)
329
func archiveOldSessions(olderThan: Date) -> Int
330
// Returns count archived
331

            
332
// Cleanup from iCloud
333
func deleteFromCloudKit(sessionID: UUID)
334
// MUST: permanent (CloudKit deletion, can't undo)
335
```
336

            
337
## Comportamente critice
338

            
339
### Concurrent saves
340

            
341
```
342
User: Start session on meter A
343
App: Saves session A
344
User: Also start session on meter B
345
App: Saves session B
346
⟹ Both saved (transactions independent)
347
```
348

            
349
**MUST**: Core Data transactions isolate (ACID)
350
**MUST**: CloudKit syncs maintain order
351

            
352
### CloudKit unavailable
353

            
354
```
355
Device offline
356
App saves to Core Data ✓
357
CloudKit sync queued
358
User goes online
359
Sync resumes automatically
360
⟹ Transparent
361
```
362

            
363
**MUST**: Queue pending changes
364
**SHOULD**: Retry with backoff
365
**MUST**: Persist queue to disk (survive app restart)
366

            
367
### Duplicate on restore
368

            
369
```
370
Device A: 100 sessions synced to iCloud
371
User: Restore from backup
372
Device B: Pulls 100 sessions
373
Device A: Restarts, sees 100 sessions (already synced)
374
⟹ Deduplication needed
375
```
376

            
377
**MUST**: Deduplicate by session ID
378
**SHOULD**: Keep most recent version
379

            
380
## Testare
381

            
382
### Unit tests
383

            
384
```swift
385
test_saveSession_PersistsToCoreData()
386
test_loadSessions_ByDevice()
387
test_loadCheckpoints_Ordered()
388
test_downsampleCurve_ReducesFactor()
389
test_cloudSyncFilters_SessionsUnder5Min()
390
test_archiveOldSessions_By Date()
391
test_deduplicateOnRestore()
392
```
393

            
394
### Integration tests
395

            
396
- [ ] Save session → reload → same data
397
- [ ] CloudKit sync without network (queued)
398
- [ ] Resume CloudKit sync when online
399
- [ ] Downsample 7+ day old sessions
400
- [ ] Archive 1+ year old sessions
401
- [ ] Delete from CloudKit (permanent)
402

            
403
## Performance considerations
404

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

            
413
## Dependenţe
414

            
415
- Core Data: NSManagedObjectContext
416
- CloudKit: NSPersistentCloudKitContainer
417
- [Charging Monitoring](./ChargingMonitoring.md): input sessions
418
- [Charge Curve Isolation](./ChargeCurveIsolation.md): isolated data
419
- File system: archiving
420

            
421
## References
422

            
423
- [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
424
- Core Data Performance Tuning: https://developer.apple.com/videos/play/wwdc2021/10190/
425
- CloudKit limits: https://developer.apple.com/icloud/cloudkit/