|
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/
|