Showing 16 changed files with 4351 additions and 0 deletions
+32 -0
AGENTS.md
@@ -0,0 +1,32 @@
1
+# Agent Guide
2
+
3
+This project has dedicated specifications to keep implementation work aligned and to prevent agent drift.
4
+
5
+Before changing code, read the documentation that matches the touched area:
6
+
7
+- `Documentation/API Reference/README.md`
8
+  Entry point for entity, operation, invariant, and test expectations.
9
+- `Documentation/API Reference/UI_ALIGNMENT.md`
10
+  UI-to-spec mapping and known alignment gaps.
11
+- `Documentation/Project Structure and Naming.md`
12
+  Folder, naming, and view-organization rules.
13
+- `Documentation/Research Resources/README.md`
14
+  Vendor manuals, protocol notes, and reverse-engineering material.
15
+
16
+## Working Rules
17
+
18
+- Treat `MUST` items in `Documentation/API Reference/` as required behavior.
19
+- Treat `SHOULD` items as expected behavior; regressions need a reason and documentation update.
20
+- If code and documentation disagree, do not silently choose one. Update the implementation, update the spec, or call out the conflict.
21
+- Keep feature work scoped to the relevant model, view, or operation described in the docs.
22
+- When adding behavior, update the matching API Reference page if the behavior changes an invariant, public API, storage shape, sync behavior, or user-visible workflow.
23
+- Prefer existing project patterns over new abstractions unless the docs or surrounding code clearly support the change.
24
+
25
+## Quick Routing
26
+
27
+- Meter Bluetooth, connection, measurements: `Documentation/API Reference/Meter.md`, `BluetoothDiscovery.md`, `Operations.md`
28
+- Charge sessions and charged devices: `ChargedDevice.md`, `ChargingMonitoring.md`, `ChargeCurveIsolation.md`, `ChargeCurveStorage.md`
29
+- Capacity and consumption logic: `CapacityMeasurement.md`, `ConsumptionMeasurement.md`, `IdleConsumptionMeasurement.md`
30
+- Powerbank behavior: `Powerbank.md`
31
+- CloudKit and cross-device data integrity: `CloudKitSync.md`, `Charge Session Integrity and Conflict Healing.md`
32
+- UI changes: `UI_ALIGNMENT.md` and the relevant operation/entity page
+328 -0
Documentation/API Reference/BluetoothDiscovery.md
@@ -0,0 +1,328 @@
1
+# Bluetooth Discovery
2
+
3
+Mecanismul de descoperire şi conectare a dispozitivelor Bluetooth.
4
+
5
+## Arhitectură
6
+
7
+### Components
8
+
9
+1. **BluetoothManager**: coordonează CBCentralManager şi descoperirile
10
+2. **BluetoothRadio**: interfață spre Core Bluetooth low-level
11
+3. **BluetoothSerial**: comunicare pe caracteristici UART
12
+4. **MeterCapabilities**: detectează tip meter (UM25C, UM34C, TC66C)
13
+
14
+## Scanning
15
+
16
+### Lifecycle
17
+
18
+```
19
+User opens app
20
+  ↓
21
+SceneDelegate calls appData.activateCloudDeviceSync()
22
+  ↓
23
+BluetoothManager starts CBCentralManager
24
+  ↓
25
+CBCentralManager begins scanning
26
+  ↓
27
+didDiscoverPeripheral → filter by service UUIDs
28
+  ↓
29
+Check device profile (class, capabilities)
30
+  ↓
31
+Add to discoveredMeters list (UI refresh)
32
+```
33
+
34
+### Service UUIDs
35
+
36
+```swift
37
+let targetServices = [
38
+    CBUUID(string: "FFF0"),    // UM25C, UM34C
39
+    CBUUID(string: "180D"),    // TC66C (generic service)
40
+]
41
+```
42
+
43
+- **MUST**: Scan cu service UUIDs specifice (nu scan generic)
44
+- **SHOULD**: Filter prin manufacturer data dacă posibil
45
+- **REASON**: Reduce energy drain, reduce noise
46
+
47
+### Advertisement parsing
48
+
49
+Meter advertise-ază:
50
+- Device name: de ex "UM25C-XXXX" sau "TC66-XXXX"
51
+- Services: FFF0 (UM series) sau 180D (TC66)
52
+- Manufacturer data: RDTech identifier
53
+
54
+```swift
55
+if let manufacturerData = advertisement[CBAdvertisementDataManufacturerDataKey] as? Data {
56
+    let manufacturerId = manufacturerData.withUnsafeBytes { $0.load(as: UInt16.self) }
57
+    if manufacturerId == 0x5449 { // RDTech
58
+        // Possible UM meter
59
+    }
60
+}
61
+```
62
+
63
+- **SHOULD**: Parse manufacturer data pentru identificare rapidă
64
+- **MAY**: Use device name ca fallback (less reliable)
65
+
66
+### Device identification
67
+
68
+```swift
69
+func identifyMeterType(name: String, services: [CBUUID]) -> Model {
70
+    if name.contains("UM25C") || services.contains(FFF0) {
71
+        return .UM25C
72
+    } else if name.contains("UM34C") || services.contains(FFF0) {
73
+        return .UM34C
74
+    } else if name.contains("TC66") || services.contains(180D) {
75
+        return .TC66C
76
+    }
77
+    return .unknown
78
+}
79
+```
80
+
81
+- **MUST**: Trebuie să identific corect tipul de meter
82
+- **SHOULD**: Use name + services (redundant checks)
83
+- **MUST**: Fallback la `.unknown` dacă uncertain
84
+
85
+## Connection
86
+
87
+### Initiation
88
+
89
+```
90
+User taps meter in list
91
+  ↓
92
+AppData calls meter.connect()
93
+  ↓
94
+BluetoothManager calls cbCentralManager.connect(peripheral)
95
+  ↓
96
+didConnect: state → peripheralConnected
97
+  ↓
98
+Discover services & characteristics
99
+```
100
+
101
+### Service/Characteristic discovery
102
+
103
+```swift
104
+// For UM series (FFF0)
105
+let targetService = CBUUID(string: "FFF0")
106
+let readCharacteristic = CBUUID(string: "FFF1")  // read measurements
107
+let writeCharacteristic = CBUUID(string: "FFF2") // send commands
108
+
109
+// For TC66 (180D)
110
+let heartRate = CBUUID(string: "2A37")  // uses standard HRM characteristic
111
+```
112
+
113
+- **MUST**: Discover services → discover characteristics (ordered)
114
+- **MUST**: Find expected characteristics, fail if not found
115
+- **SHOULD**: Subscribe to notifications pentru updates
116
+- **TIMEOUT**: 5s max pentru service discovery
117
+
118
+### State transitions
119
+
120
+```
121
+peripheralConnected
122
+  ↓
123
+discoveringServices (discovering FFF0 / 180D)
124
+  ↓
125
+discoveringCharacteristics (discovering FFF1, FFF2 / 2A37)
126
+  ↓
127
+peripheralReady (services + characteristics found)
128
+  ↓
129
+comunicating ↔ dataIsAvailable (steady state)
130
+```
131
+
132
+- **MUST**: Stare monotonă crescătoare (no rollback)
133
+- **SHOULD**: Log state transitions
134
+- **MUST**: Fail gracefully dacă characteristics nu sunt găsite
135
+
136
+## Communication
137
+
138
+### Measurement requests
139
+
140
+**UM series** (UM25C, UM34C):
141
+```swift
142
+// Send command to FFF2 (write characteristic)
143
+let command = UMProtocol.buildMeasurementRequest()
144
+peripheral.writeValue(command, for: writeCharacteristic, type: .withResponse)
145
+
146
+// Receive on FFF1 (read characteristic)
147
+// Parse payload (voltage, current, power, temperature, etc.)
148
+let measurement = UMProtocol.parseMeasurement(data)
149
+```
150
+
151
+**TC66C**:
152
+```swift
153
+// Uses standard HRM (Heart Rate Measurement) characteristic
154
+// Value format: flags byte + heart_rate_value (2 bytes)
155
+// Repurposed for power data: MSB = watts, LSB = amps (approximation)
156
+let measurement = TC66Protocol.parseMeasurement(data)
157
+```
158
+
159
+- **MUST**: Parse protocol-specific payloads
160
+- **MUST**: Validate checksum (if applicable)
161
+- **SHOULD**: Handle invalid/truncated payloads gracefully
162
+- **TIMEOUT**: 3s per measurement request
163
+
164
+### Write commands
165
+
166
+UM series supports commands:
167
+- Request measurement: `0x00 0xF0 0xA0 0x1B` (+ checksum)
168
+- Set time: `0x02 ...` (timestamp)
169
+- Set calibration: (advanced)
170
+
171
+```swift
172
+peripheral.writeValue(
173
+    command,
174
+    for: writeCharacteristic,
175
+    type: .withResponse  // MUST: wait for ACK
176
+)
177
+```
178
+
179
+- **MUST**: Use `.withResponse` pentru command-uri critice
180
+- **MAY**: Use `.withoutResponse` pentru bulk writes
181
+- **SHOULD**: Validate response ACK
182
+
183
+## Disconnection handling
184
+
185
+### Intentional disconnect
186
+
187
+```
188
+User taps "Disconnect"
189
+  ↓
190
+meter.disconnect()
191
+  ↓
192
+BluetoothManager calls cbCentralManager.cancelPeripheralConnection()
193
+  ↓
194
+didDisconnect: state → peripheralNotConnected
195
+```
196
+
197
+- **MUST**: Anulează pending operations
198
+- **MUST**: Anulează auto-reconnect logic
199
+- **MUST**: Eliberează callbacks
200
+
201
+### Unintentional disconnect (BT drop)
202
+
203
+```
204
+BT device disconnects (out of range / powered off / interference)
205
+  ↓
206
+didDisconnect event (didDisconnect reason: optional)
207
+  ↓
208
+state → peripheralNotConnected
209
+  ↓
210
+Auto-reconnect logic starts (with backoff)
211
+```
212
+
213
+- **MUST**: Detecta unintentional drops (log reason dacă available)
214
+- **SHOULD**: Incepe auto-reconnect cu backoff exponential
215
+- **MUST**: Anulează dacă user disconnect manual (flag)
216
+
217
+## Auto-reconnect
218
+
219
+### Backoff strategy
220
+
221
+```
222
+Attempt 1: 1s delay
223
+Attempt 2: 2s delay
224
+Attempt 3: 4s delay
225
+Attempt 4: 8s delay
226
+Attempt 5: 16s delay
227
+Attempt 6: 32s delay
228
+Attempt 7+: 60s delay (capped)
229
+Max attempts: 3
230
+```
231
+
232
+- **MUST**: Exponential backoff (2^n, capped at 60s)
233
+- **MUST**: Max 3 consecutive retry-uri
234
+- **MUST**: Stop retry dacă user disconnect manual
235
+- **SHOULD**: Log fiecare retry attempt
236
+
237
+### Trigger conditions
238
+
239
+- **MUST**: Activate automatic reconnect doar dacă user conectase anterior
240
+- **MUST**: Disable dacă user disconnect manual
241
+- **MUST**: Disable dacă app goes background > 10 min
242
+- **SHOULD**: Resume reconnect dacă app returns foreground
243
+
244
+## Testing
245
+
246
+### Unit tests
247
+
248
+```swift
249
+test_scanFiltersByServiceUUIDs()
250
+test_deviceIdentification_UM25C()
251
+test_deviceIdentification_UM34C()
252
+test_deviceIdentification_TC66C()
253
+test_connectionStateTransitions()
254
+test_serviceDiscovery_UM25C()
255
+test_characteristicDiscovery_UM25C()
256
+test_measurementParsing_ValidPayload()
257
+test_measurementParsing_InvalidPayload()
258
+test_disconnectCleansUp()
259
+test_unintentionalDropDetected()
260
+test_autoReconnectBackoff_Exponential()
261
+test_autoReconnectStops_OnManualDisconnect()
262
+```
263
+
264
+### Integration tests
265
+
266
+- [ ] Scan detects available meters
267
+- [ ] Device type identified correctly
268
+- [ ] Connect → service discover → ready (full flow)
269
+- [ ] Measurement received and parsed
270
+- [ ] Unintentional drop detected + reconnect
271
+- [ ] Auto-reconnect respects backoff timing
272
+- [ ] Manual disconnect stops auto-reconnect
273
+
274
+## Error handling
275
+
276
+### Scan errors
277
+
278
+```
279
+Error: CBError.unknown
280
+Error: CBError.managerStatePoweredOff
281
+Error: CBError.invalidParameters
282
+```
283
+
284
+Handling:
285
+- Retry scan periodically
286
+- Notify UI: "Bluetooth unavailable"
287
+
288
+### Connection errors
289
+
290
+```
291
+Error: peripheral not found
292
+→ Retry with backoff
293
+
294
+Error: timeout (no services found)
295
+→ Disconnect + retry
296
+
297
+Error: security/pairing required
298
+→ Notify user: "Pair device in Settings"
299
+```
300
+
301
+### Communication errors
302
+
303
+```
304
+Error: write failed
305
+→ Retry measurement request
306
+
307
+Error: invalid payload
308
+→ Log error, skip measurement
309
+
310
+Error: characteristic not found
311
+→ Disconnect + mark incompatible
312
+```
313
+
314
+## Dependencies
315
+
316
+- `CoreBluetooth`: CBCentralManager, CBPeripheral
317
+- `UMProtocol`: payload parsing for UM series
318
+- `TC66Protocol`: payload parsing for TC66C
319
+- `MeterCapabilities`: device type detection
320
+- `AppData`: orchestration
321
+
322
+## References
323
+
324
+- [UMProtocol.swift](../../USB%20Meter/Model/UMProtocol.swift)
325
+- [TC66Protocol.swift](../../USB%20Meter/Model/TC66Protocol.swift)
326
+- [BluetoothManager.swift](../../USB%20Meter/Model/BluetoothManager.swift)
327
+- [External documentation](https://sigrok.org/wiki/RDTech_UM_series)
328
+- [TC66C reverse-engineering notes](../Research%20Resources/Payload%20Notes/TC66C%20Transport%20and%20Payload%20Working%20Note.md)
+301 -0
Documentation/API Reference/CapacityMeasurement.md
@@ -0,0 +1,301 @@
1
+# Capacity Measurement Operation
2
+
3
+Măsurarea şi calculul capacităţii unei baterii din sesiunile de încărcare.
4
+
5
+## Responsabilități
6
+
7
+- Determinarea capacităţii reale a unei baterii (mAh)
8
+- Validarea completitudinii datelor (0% → 100%)
9
+- Calculul din energie + voltaje nominal
10
+- Tracking capacity degradation (baterie veche)
11
+
12
+## Concepte
13
+
14
+### Capacity vs Energy
15
+
16
+- **Capacity (mAh)**: Cantitate de sarcină. Ex: 3000 mAh
17
+- **Energy (Wh)**: Capacitate × voltaj nominal. Ex: 3000mAh × 3.7V = 11.1Wh
18
+
19
+Formula:
20
+```
21
+Capacity (mAh) = Energy (Wh) × 1000 / Nominal Voltage (V)
22
+```
23
+
24
+Exemplu:
25
+```
26
+Energy = 11.1 Wh
27
+Nominal voltage = 3.7V (typical Li-ion)
28
+Capacity = 11.1 × 1000 / 3.7 = 3000 mAh
29
+```
30
+
31
+## Invarianţi
32
+
33
+- **MUST**: Capacity > 0 mAh (nu poate fi 0 sau negativ)
34
+- **MUST**: Capacity ≤ 10000 mAh pentru device-uri obişnuite
35
+- **MUST**: Sesiune validă pentru capacity learning trebuie: battery 0% → 100%
36
+- **MUST**: Nominal voltage trebuie specificat (nu se calculează)
37
+- **MUST**: Capacity learning doar din **complete discharge cycles**
38
+- **SHOULD**: Capacity măsurat ≥ 80% din rated capacity (bateria e "OK")
39
+- **MAY**: Capacity < 80% = aged battery, notify user
40
+
41
+## Types of sessions
42
+
43
+### Full discharge (valid pentru learning)
44
+
45
+```
46
+Battery: 100% → ... → 0%
47
+Device: ON throughout
48
+Meter: record-ează energie totală
49
+
50
+⟹ Capacity = Energy / Nominal_Voltage
51
+```
52
+
53
+**Invarianţi:**
54
+- **MUST**: Start @ ~100%, end @ ~0%
55
+- **MUST**: Fără interruption (meter disconnect = invalid)
56
+- **MUST**: Device rămâne ON (nu suspend mid-session)
57
+
58
+### Partial charge
59
+
60
+```
61
+Battery: 20% → 50%
62
+Device: OFF (charging)
63
+
64
+⟹ Cannot infer capacity (unknown starting level)
65
+```
66
+
67
+**MUST**: Ignore pentru capacity learning
68
+
69
+### Trickle charge (edge case)
70
+
71
+```
72
+Battery: 95% → 100%
73
+Current: <50mA (charging optimizer active)
74
+Duration: >2 hours
75
+
76
+⟹ Energy negligible, skip
77
+```
78
+
79
+## Algorithm
80
+
81
+### Capacity learning from discharge
82
+
83
+```swift
84
+func learnCapacityFromDischarge(session: ChargeRecord) -> Double? {
85
+    // 1. Validate session
86
+    guard isCompleteDischargeSession(session) else { return nil }
87
+    
88
+    // 2. Extract energy
89
+    let energyWh = session.totalEnergy
90
+    
91
+    // 3. Get nominal voltage
92
+    let nominalVoltageV = device.nominalBatteryVoltage // 3.7, 5.0, etc
93
+    
94
+    // 4. Calculate capacity
95
+    let capacityMah = energyWh * 1000 / nominalVoltageV
96
+    
97
+    // 5. Validate result
98
+    guard capacityMah > 0 && capacityMah <= 10000 else {
99
+        logWarning("Capacity outside bounds: \(capacityMah) mAh")
100
+        return nil
101
+    }
102
+    
103
+    return capacityMah
104
+}
105
+
106
+func isCompleteDischargeSession(_ session: ChargeRecord) -> Bool {
107
+    // Check battery levels
108
+    guard let startBattery = session.startBatteryPercent,
109
+          let endBattery = session.endBatteryPercent else {
110
+        return false
111
+    }
112
+    
113
+    // Range must span ~100%
114
+    let range = startBattery - endBattery
115
+    guard range >= 90 else {
116
+        logWarning("Incomplete discharge: \(range)% only")
117
+        return false
118
+    }
119
+    
120
+    // Device must be ON during discharge
121
+    guard session.isCompletelyRecorded else {
122
+        logWarning("Session incomplete (meter disconnect)")
123
+        return false
124
+    }
125
+    
126
+    return true
127
+}
128
+```
129
+
130
+### Weighted moving average
131
+
132
+Pentru stabilitate, combinaţi multiple measurements:
133
+
134
+```swift
135
+func estimateCapacity(from sessions: [ChargeRecord]) -> Double? {
136
+    let validSessions = sessions.compactMap { session in
137
+        learnCapacityFromDischarge(session)
138
+    }
139
+    
140
+    guard !validSessions.isEmpty else { return nil }
141
+    
142
+    // Weight by recency: newer sessions count more
143
+    let weights = validSessions.indices.map { i in
144
+        Double(i + 1) / Double(validSessions.count)
145
+    }
146
+    
147
+    let weightedSum = zip(validSessions, weights)
148
+        .map { capacity, weight in capacity * weight }
149
+        .reduce(0, +)
150
+    
151
+    let capacityMah = weightedSum / weights.reduce(0, +)
152
+    
153
+    return capacityMah
154
+}
155
+```
156
+
157
+## API Public
158
+
159
+### Proprietăţi
160
+
161
+| Proprietate | Tip | Descriere |
162
+|---|---|---|
163
+| `nominalBatteryVoltage` | Double | V (ex: 3.7) |
164
+| `measuredCapacity` | Double? | mAh din learning |
165
+| `ratedCapacity` | Double? | mAh din device spec |
166
+| `capacity_Health` | Double | measured / rated (%) |
167
+| `lastCapacityMeasurement` | Date? | When learned |
168
+
169
+### Metode
170
+
171
+```swift
172
+// Learning
173
+func learnCapacity(from session: ChargeRecord) -> Double?
174
+func learnCapacity(from sessions: [ChargeRecord]) -> Double?
175
+
176
+// Validation
177
+func isValidCapacityMeasurement(_ mah: Double) -> Bool
178
+func isCompleteDischargeSession(_ session: ChargeRecord) -> Bool
179
+func estimateSessionCompleteness(_ session: ChargeRecord) -> Double
180
+
181
+// Health
182
+func batteryHealth() -> Double // measured / rated
183
+func isAgedBattery() -> Bool // health < 80%
184
+```
185
+
186
+## Comportamente critice
187
+
188
+### Incompletă sesiune → skip learning
189
+
190
+```
191
+Device: 30% → 15%
192
+Meter: record-ează 5 Wh
193
+⟹ Skip (partial discharge, nu putem infera capacity)
194
+
195
+Device: 100% → 5% (but meter lost signal @ 20%)
196
+⟹ Skip (incomplete recording)
197
+```
198
+
199
+**MUST**: Validate completeness înainte de learning
200
+
201
+### Multiple discharge cycles
202
+
203
+```
204
+Session 1: 100% → 0%, measured 12 Wh → 3200 mAh
205
+Session 2: 100% → 0%, measured 11.5 Wh → 3100 mAh
206
+Session 3: 100% → 0%, measured 11.2 Wh → 3000 mAh
207
+⟹ Average: 3100 mAh (weighted toward recent)
208
+```
209
+
210
+**SHOULD**: Combinaţi sesiuni (moving average)
211
+**MUST**: Keep history pentru trend analysis
212
+
213
+### Battery aging
214
+
215
+```
216
+Device created: 2024-01-01, rated 3000 mAh
217
+Measured capacities:
218
+  - 2024-01-15: 2950 mAh (98% health)
219
+  - 2024-06-15: 2700 mAh (90% health)
220
+  - 2025-01-15: 2400 mAh (80% health) ← aged
221
+⟹ Notify user: "Battery health below 80%"
222
+```
223
+
224
+**SHOULD**: Track degradation curve
225
+**SHOULD**: Notify @ 80% health
226
+**MAY**: Suggest battery replacement @ 70%
227
+
228
+## Testare
229
+
230
+### Unit tests
231
+
232
+```swift
233
+test_learnCapacity_ValidDischargeSession()
234
+test_learnCapacity_PartialDischarge_Ignored()
235
+test_learnCapacity_IncompleteRecording_Ignored()
236
+test_isCompleteDischargeSession_ValidRange()
237
+test_isCompleteDischargeSession_MinRange()
238
+test_estimateCapacity_MultiplesSessions()
239
+test_estimateCapacity_WeightedByRecency()
240
+test_capacityMustBePositive()
241
+test_capacityMustBeBounded()
242
+test_batteryHealth_Calculation()
243
+test_isAgedBattery_Threshold()
244
+```
245
+
246
+### Integration tests
247
+
248
+- [ ] Single complete discharge 100%→0%: capacity measured
249
+- [ ] Partial discharge: capacity NOT measured
250
+- [ ] Multiple sessions: weighted average
251
+- [ ] Aged battery detected @ 80%
252
+- [ ] Capacity persistent în Core Data
253
+- [ ] Battery health calculated correctly
254
+
255
+## Edge cases
256
+
257
+### Rapid discharge (high power draw)
258
+
259
+```
260
+Battery: 100% → 0% in 30 minutes
261
+Power draw: 50W constant
262
+Energy: large, measured accurately
263
+⟹ Normal discharge, capacity = energy / voltage
264
+```
265
+
266
+**SHOULD**: Accept și use pentru learning
267
+
268
+### Trickle charge (low power)
269
+
270
+```
271
+Battery: 95% → 100%
272
+Charging current: 10mA (very low)
273
+Duration: 4 hours
274
+Energy: negligible (~0.2Wh)
275
+⟹ Skip (too little data)
276
+```
277
+
278
+**MUST**: Ignore (capacity = energy/voltage → false low)
279
+
280
+### Voltage variance
281
+
282
+```
283
+Nominal voltage: 3.7V
284
+But actual range: 3.2V (low) → 4.2V (full)
285
+Energy measured: 11Wh
286
+Capacity calculation: depends on which voltage used
287
+```
288
+
289
+**SHOULD**: Use nominal (convention)
290
+**SHOULD**: Log warning dacă voltage deviates
291
+
292
+## Dependencies
293
+
294
+- [Charging Monitoring](./ChargingMonitoring.md): session data
295
+- [Charge Curve Storage](./ChargeCurveStorage.md): historical curves
296
+- Core Data: `ChargeSession.startBatteryPercent`, `endBatteryPercent`
297
+
298
+## References
299
+
300
+- [Powerbank Category](../Powerbank%20Category.md) — battery reporting modes
301
+- Scientific: https://en.wikipedia.org/wiki/Battery_capacity
+337 -0
Documentation/API Reference/ChargeCurveIsolation.md
@@ -0,0 +1,337 @@
1
+# Charge Curve Isolation Operation
2
+
3
+Izolarea porţiunii relevante a curbei de încărcare: eliminarea zgomotului pre/post-charging.
4
+
5
+## Responsabilități
6
+
7
+- Detectarea punctului de start real al încărcării (0 watts baseline)
8
+- Detectarea punctului de end real al încărcării (100% sau limit)
9
+- Eliminarea zgomotului (ambient AC, meter offset)
10
+- Determinarea timpului efectiv de charging
11
+
12
+## Problema
13
+
14
+**Raw data from meter:**
15
+```
16
+00:00 - 0.0W (pre-charge noise)
17
+00:30 - 0.5W (background draw)
18
+01:00 - 15.0W ← REAL START
19
+01:30 - 18.5W
20
+02:00 - 16.2W
21
+...
22
+05:00 - 2.5W (charging optimizer active)
23
+05:30 - 1.0W
24
+06:00 - 0.2W ← REAL END
25
+06:15 - 0.0W (post-charge, device removed)
26
+```
27
+
28
+**Sarcini:**
29
+1. Find real start (@ 01:00)
30
+2. Find real end (@ 06:00)
31
+3. Extract interval [01:00, 06:00]
32
+4. Ignore pre/post noise
33
+
34
+## Invarianţi
35
+
36
+- **MUST**: `real_start_time > measurement_start_time` (or equal)
37
+- **MUST**: `real_end_time <= measurement_end_time` (or equal)
38
+- **MUST**: `real_start < real_end`
39
+- **MUST**: Threshold determination trebuie consistent (nu random)
40
+- **SHOULD**: Isolated curve ≥ 90% din total recorded measurements
41
+- **MAY**: Isolated curve poate fi < 10% din total (very short charge)
42
+
43
+## Detection algorithm
44
+
45
+### Power threshold method
46
+
47
+```swift
48
+func findChargeStart(measurements: [Measurement]) -> Int? {
49
+    let powerThreshold = 5.0 // Watts
50
+    let minConsecutive = 3 // Measure must sustain > threshold for 3 samples
51
+    
52
+    var sustainCount = 0
53
+    for (i, measurement) in measurements.enumerated() {
54
+        if measurement.power > powerThreshold {
55
+            sustainCount += 1
56
+            if sustainCount >= minConsecutive {
57
+                return i - minConsecutive + 1
58
+            }
59
+        } else {
60
+            sustainCount = 0
61
+        }
62
+    }
63
+    return nil
64
+}
65
+
66
+func findChargeEnd(measurements: [Measurement]) -> Int? {
67
+    let powerThreshold = 1.0 // Watts (lower at end, trickle charge)
68
+    let minConsecutive = 5 // Stay below threshold for 5 samples
69
+    
70
+    var sustainCount = 0
71
+    for i in stride(from: measurements.count - 1, through: 0, by: -1) {
72
+        let measurement = measurements[i]
73
+        if measurement.power < powerThreshold {
74
+            sustainCount += 1
75
+            if sustainCount >= minConsecutive {
76
+                return i + 1
77
+            }
78
+        } else {
79
+            sustainCount = 0
80
+        }
81
+    }
82
+    return nil
83
+}
84
+```
85
+
86
+**Thresholds:**
87
+- Start: 5W (must exceed noise level)
88
+- End: 1W (lower, allows trickle detection)
89
+- Sustain: 3 samples @start, 5 @end (hysteresis)
90
+
91
+### Battery level method (fallback)
92
+
93
+Dacă dispositiv raportează procent:
94
+
95
+```swift
96
+func findChargeStartByBattery(measurements: [Measurement]) -> Int? {
97
+    // Find first battery level jump
98
+    for i in 1..<measurements.count {
99
+        let prev = measurements[i-1]
100
+        let curr = measurements[i]
101
+        
102
+        let batteryChange = (curr.batteryPercent ?? 0) - (prev.batteryPercent ?? 0)
103
+        if batteryChange > 1.0 {  // >1% jump = real charge
104
+            return i - 1
105
+        }
106
+    }
107
+    return nil
108
+}
109
+
110
+func findChargeEndByBattery(measurements: [Measurement]) -> Int? {
111
+    // Find last battery level change
112
+    for i in stride(from: measurements.count - 1, through: 1, by: -1) {
113
+        let prev = measurements[i-1]
114
+        let curr = measurements[i]
115
+        
116
+        let batteryChange = (curr.batteryPercent ?? 0) - (prev.batteryPercent ?? 0)
117
+        if batteryChange > 0.5 {  // Still charging
118
+            return i
119
+        }
120
+    }
121
+    return nil
122
+}
123
+```
124
+
125
+### Combined method (best)
126
+
127
+```swift
128
+func isolateChargeRange(measurements: [Measurement]) -> (start: Int, end: Int)? {
129
+    // Primary: use power threshold
130
+    guard var start = findChargeStart(measurements: measurements),
131
+          var end = findChargeEnd(measurements: measurements) else {
132
+        // Fallback: use battery level
133
+        guard var start = findChargeStartByBattery(measurements: measurements),
134
+              var end = findChargeEndByBattery(measurements: measurements) else {
135
+            return nil
136
+        }
137
+        return (start, end)
138
+    }
139
+    
140
+    // Validate result
141
+    guard start < end && (end - start) >= 10 else {
142
+        return nil  // Too short (< 10 seconds @ 1Hz)
143
+    }
144
+    
145
+    return (start, end)
146
+}
147
+```
148
+
149
+## API Public
150
+
151
+### Metode
152
+
153
+```swift
154
+// Detection
155
+func findChargeStart(measurements: [Measurement]) -> Int?
156
+func findChargeEnd(measurements: [Measurement]) -> Int?
157
+func isolateChargeRange(measurements: [Measurement]) -> (start: Int, end: Int)?
158
+
159
+// Extraction
160
+func extractIsolatedCurve(measurements: [Measurement]) 
161
+    -> [Measurement]?
162
+
163
+// Validation
164
+func isValidChargeRange(start: Int, end: Int, total: Int) -> Bool
165
+func estimateNoisePercentage(measurements: [Measurement]) -> Double
166
+```
167
+
168
+### Rezultat
169
+
170
+```swift
171
+struct IsolatedChargeData {
172
+    let originalMeasurements: [Measurement]
173
+    let isolatedMeasurements: [Measurement]
174
+    let startIndex: Int
175
+    let endIndex: Int
176
+    let realStartTime: Date
177
+    let realEndTime: Date
178
+    let noisePercentagePre: Double
179
+    let noisePercentagePost: Double
180
+    
181
+    var effectiveChargeTime: TimeInterval {
182
+        realEndTime.timeIntervalSince(realStartTime)
183
+    }
184
+}
185
+```
186
+
187
+## Comportamente critice
188
+
189
+### Threshold sensitivity
190
+
191
+**Problem: threshold prea mare**
192
+```
193
+Threshold: 10W
194
+Reality: charger 12W on iPhone 15
195
+Result: detectează corect ✓
196
+But: low-power charger 5W → missed (fails)
197
+```
198
+
199
+**Problem: threshold prea mic**
200
+```
201
+Threshold: 0.5W
202
+Reality: background AC noise 1.5W
203
+Result: detectează noise, invalid start (fails)
204
+```
205
+
206
+**Solution:** Adaptive threshold:
207
+```swift
208
+let baselineNoise = measurements.prefix(10).map { $0.power }.max() ?? 0
209
+let powerThreshold = baselineNoise * 1.5 + 2.0  // 50% margin + 2W
210
+```
211
+
212
+### Short charges
213
+
214
+```
215
+Charge duration: 30 seconds (e.g., quick top-up)
216
+Measurements: 30 samples @ 1Hz
217
+Noise isolation: 3 samples start + 5 samples end = 8 samples
218
+Remaining: 22 samples (73% valid)
219
+⟹ Valid
220
+```
221
+
222
+**SHOULD**: Accept short charges
223
+**MUST**: Still isolate pre/post
224
+
225
+### Noisy environment
226
+
227
+```
228
+Pre-charge noise: 2W baseline (poor outlet)
229
+Charge power: 18W
230
+Power jump: 16W (obvious)
231
+⟹ Easy to detect
232
+
233
+Vs.
234
+
235
+Pre-charge noise: 8W (strong interference)
236
+Charge power: 10W
237
+Power jump: 2W (ambiguous!)
238
+⟹ Hard, need fallback (battery level)
239
+```
240
+
241
+**SHOULD**: Use combined method (power + battery)
242
+**MUST**: Fall back if ambiguous
243
+
244
+### Charging profile changes
245
+
246
+```
247
+Curve:
248
+- 0-1h: 20W (fast charge phase)
249
+- 1-2h: 10W (medium phase)
250
+- 2-3h: 3W (taper phase)
251
+- 3h+: 0.5W (trickle/done)
252
+
253
+Isolated region: 0h-3h (valid)
254
+Threshold: 1W (catches all phases)
255
+⟹ Correct
256
+```
257
+
258
+**MUST**: Detect end even with tapering
259
+
260
+## Testare
261
+
262
+### Unit tests
263
+
264
+```swift
265
+test_findChargeStart_ValidThreshold()
266
+test_findChargeStart_NoiseIgnored()
267
+test_findChargeEnd_ValidThreshold()
268
+test_findChargeEnd_WithTrickleCharge()
269
+test_isolateChargeRange_ValidData()
270
+test_isolateChargeRange_TooShortIgnored()
271
+test_isolateChargeRange_HighNoise_FallbackToBattery()
272
+test_isValidChargeRange_StartBeforeEnd()
273
+test_isValidChargeRange_MinimumDuration()
274
+test_estimateNoisePercentage()
275
+test_extractIsolatedCurve_PreservesMeasurements()
276
+```
277
+
278
+### Integration tests
279
+
280
+- [ ] Raw data: 6h recording, 15 min real charge → correctly isolated
281
+- [ ] High pre-charge noise (8W) → still detected
282
+- [ ] Low power charger (5W) → detected
283
+- [ ] Multiple charge phases → end detected at trickle
284
+- [ ] Isolated curve used for energy calculation
285
+
286
+## Edge cases
287
+
288
+### No charge (meter ON, device not charging)
289
+
290
+```
291
+Measurements: all ~0W
292
+Start detection: fails (no power jump)
293
+End detection: fails (no charging detected)
294
+Result: (nil, nil)
295
+⟹ Skip session (valid, no charging occurred)
296
+```
297
+
298
+**MUST**: Handle gracefully
299
+
300
+### Meter added after charge started
301
+
302
+```
303
+Recording: 03:00-07:00 (4 hours)
304
+Real charge: 01:00-06:00 (5 hours, but missed first 2h)
305
+Measurements: 10W constant (already in charge phase)
306
+Start: 03:00 (best guess)
307
+End: 06:00 (detected from power drop)
308
+⟹ Partial isolation OK
309
+```
310
+
311
+**SHOULD**: Use what data we have
312
+**MAY**: Mark as "incomplete start"
313
+
314
+### USB power delivery with negotiation
315
+
316
+```
317
+Time 00:00: Meter ON, no device
318
+Time 00:10: Device plugged in, negotiation 100ms
319
+Time 00:12: Charging starts (power jump 20W)
320
+Samples: ?, ?, [19.5W, 19.8W, ...] ← isolated start
321
+⟹ Correct
322
+```
323
+
324
+**SHOULD**: OK, negotiation time negligible
325
+
326
+## Dependencies
327
+
328
+- [Charging Monitoring](./ChargingMonitoring.md): input measurements
329
+- [Charge Curve Storage](./ChargeCurveStorage.md): store isolated data
330
+- Core Data: battery levels (fallback)
331
+
332
+## Notes
333
+
334
+- Power threshold: device/charger dependent, may need tuning
335
+- Battery level fallback: only if power method ambiguous
336
+- Resolution: 1Hz (1000ms), ~1W precision typical
337
+- Legat: [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
+425 -0
Documentation/API Reference/ChargeCurveStorage.md
@@ -0,0 +1,425 @@
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/
+147 -0
Documentation/API Reference/ChargedDevice.md
@@ -0,0 +1,147 @@
1
+# ChargedDevice Entity
2
+
3
+Reprezentarea unui dispozitiv care se încarcă prin meter.
4
+
5
+## Responsabilități
6
+
7
+- Modelarea unui dispozitiv (iPhone, Watch, Charger, Powerbank, Other)
8
+- Gestionarea sesiunilor de încărcare
9
+- Colectarea de măsurători durante sesiune
10
+- Calculul integrității şi detectării conflictelor
11
+
12
+## Invarianţi
13
+
14
+- **MUST**: Fiecare ChargedDevice are un `id` (UUID) unic
15
+- **MUST**: O sesiune de încărcare are timestamp-uri valide: `startTime <= currentTime <= endTime`
16
+- **MUST**: Măsurătorile într-o sesiune sunt ordonate cronologic
17
+- **MUST**: O sesiune activă pe un dispozitiv trebuie să aibă un singur Meter asociat
18
+- **MUST**: Energia calculată (Wh) trebuie să fie ne-negativă
19
+- **SHOULD**: Un device cu 0 măsurători ar trebui marcat pentru curatare pe CloudKit
20
+- **MAY**: Duplicate sessions pe același MAC pot fi mergate după detectare
21
+
22
+## API Public
23
+
24
+### Proprietăţi
25
+
26
+| Proprietate | Tip | Descriere | Observaţii |
27
+|---|---|---|---|
28
+| `id` | UUID | Identificator unic | Generat la creare |
29
+| `deviceName` | String | Nume ales de utilizator | De ex: "My iPhone 15" |
30
+| `deviceClass` | ChargedDeviceClass | Tip: iPhone, Watch, Charger, Powerbank, Other | |
31
+| `kind` | ChargedDeviceKind | Device sau Charger | Derivat din `deviceClass` |
32
+| `createdAt` | Date | Data creării | Immutable |
33
+| `chargeRecords` | [ChargeRecord] | Lista sesiunilor de încărcare | Sorted by startTime |
34
+| `totalEnergy` | Double | Total Wh consumat/furnizat | Calculat din sesiuni |
35
+| `lastChargedAt` | Date? | Data ultimei sesiuni | Nullable |
36
+
37
+### Metode
38
+
39
+```swift
40
+// Gestionare sesiuni
41
+func startSession(meter: Meter) -> ChargeRecord
42
+// MUST: sessionID este UUID unic
43
+// MUST: startTime = now()
44
+// MUST: retur ChargeRecord cu status "active"
45
+
46
+func recordMeasurement(_ measurement: Measurement, in session: ChargeRecord)
47
+// MUST: measurement.timestamp > session.startTime
48
+// SHOULD: measure-urile sunt colectate la ~1Hz
49
+// MUST: nu merge dacă sesiunea e finalizată
50
+
51
+func endSession(_ record: ChargeRecord)
52
+// MUST: endTime = now()
53
+// MUST: calculează total energy din măsurători
54
+// MUST: marchez sesiunea ca "completed"
55
+
56
+func recalculateTotalEnergy()
57
+// Recalculează sum-ul de Wh din toate sesiunile
58
+// SHOULD: se apelează după conflict resolution
59
+
60
+// Naming
61
+func renameToDevice(_ newName: String)
62
+// MUST: actualizează proprietatea şi persistă
63
+
64
+// Conflict handling
65
+func mergeWith(_ other: ChargedDevice, keepingRecords: Bool = true)
66
+// MUST: combină sessions de pe ambele device-uri
67
+// MUST: remove-ă duplicates după MAC address
68
+// SHOULD: logs merge operation pentru debug
69
+```
70
+
71
+## Comportamente critice
72
+
73
+### Sesiuni de încărcare
74
+
75
+- **MUST**: Doar o singură sesiune per device poate fi activă la orice moment
76
+- **MUST**: Sesiunile completate sunt imutable
77
+- **SHOULD**: Sesiunile care durează > 24h sunt marcate cu warning
78
+- **MAY**: Sesiuni orphaned (meter deconectat > 1h) pot fi finalizate automat
79
+
80
+### Calculul energiei
81
+
82
+- **MUST**: Energia = ∑(V * A * Δt) pentru fiecare interval
83
+- **MUST**: Unitatea trebuie să fie Wh (Watt-hours)
84
+- **SHOULD**: Energia negativă în sesiune = problema în măsurători, loghează warning
85
+
86
+### Conflict detection și rezoluție
87
+
88
+Se declanşează când:
89
+1. Două sesiuni cu aceeași categorie de device în same time window
90
+2. Două sesiuni pe aceeași meter cu overlap temporal
91
+
92
+Rezoluţie:
93
+- **MUST**: Sesiunea cu mai multe măsurători = "winner"
94
+- **MUST**: Energiile sunt combinate (∑)
95
+- **SHOULD**: Old session data este archived (Core Data snapshot)
96
+- **MAY**: Notifică utilizator despre merge
97
+
98
+### State management
99
+
100
+- **MUST**: Active session nu poate fi schimbat în mod normal (read-only)
101
+- **MUST**: State tranzițion: idle → active → completed (nu poate reveni)
102
+- **SHOULD**: Completed sesiuni sunt persistent în Core Data
103
+
104
+## Testare
105
+
106
+### Unit tests
107
+
108
+```swift
109
+// Sesiuni
110
+test_startSessionCreatesValidRecord()
111
+test_endSessionCalculatesEnergy()
112
+test_activeSessions_CanOnlyBeOne()
113
+test_sessionTimeValidation()
114
+
115
+// Măsurători
116
+test_recordMeasurementFailsIfSessionEnded()
117
+test_measurementsAreOrdered()
118
+test_emptySessionsCalculateZeroEnergy()
119
+
120
+// Conflict
121
+test_mergeDetectsDuplicateSessions()
122
+test_mergeKeepsMoreCompleteMeasurements()
123
+test_mergeRecalculates_TotalEnergy()
124
+
125
+// Naming
126
+test_renameUpdatesPersistence()
127
+```
128
+
129
+### Integration tests
130
+
131
+- [ ] Device apare în Charged Devices list după start sesiune
132
+- [ ] Sesiuni complete salvate în Core Data
133
+- [ ] CloudKit sync nu pierde sesiuni
134
+- [ ] Energiile sunt consistente după conflict merge
135
+- [ ] Device name e persistent
136
+
137
+## Dependenţe
138
+
139
+- `Meter`: conectare şi măsurători
140
+- `ChargeInsightsStore`: persistență sesiuni
141
+- `CloudKitSync`: replicare iCloud
142
+
143
+## Notes
144
+
145
+Documentaţie asoc:
146
+- [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
147
+- [No Ampere-Hours in UI](../No%20Ampere-Hours%20in%20UI%20or%20Model.md)
+245 -0
Documentation/API Reference/ChargingMonitoring.md
@@ -0,0 +1,245 @@
1
+# Charging Monitoring Operation
2
+
3
+Monitorizarea unei sesiuni de încărcare: colectarea măsurătorilor, izolarea curbei şi calculul energiei.
4
+
5
+## Responsabilități
6
+
7
+- Orchestrează o sesiune de încărcare complet (start → record → end)
8
+- Colectează măsurători de la meter la intervale regulate (~1Hz)
9
+- Izolează porţiunea relevantă a curbei (fără pre/post charging noise)
10
+- Calculează totalul de energie consumată (Wh)
11
+- Persistă date în Core Data + iCloud
12
+
13
+## Invarianţi
14
+
15
+- **MUST**: Doar o sesiune activă per meter la orice moment
16
+- **MUST**: Măsurătorile sunt cronologice (sortate după timestamp)
17
+- **MUST**: Start time < end time (sauf pentru sesiunile active)
18
+- **MUST**: Energia totală ≥ 0 (nu poate fi negativă)
19
+- **MUST**: O sesiune completată este imutabilă
20
+- **SHOULD**: O sesiune durează între 5 minute şi 48 ore
21
+- **MAY**: Sesiuni orphane (meter deconectat) pot fi finalizate automat
22
+
23
+## Stadiile sesiunii
24
+
25
+```
26
+idle
27
+  ↓
28
+startSession() → starting
29
+  ↓
30
+recordMeasurement() → active (repeat)
31
+  ↓
32
+endSession() → ended
33
+  ↓
34
+completed (immutable)
35
+```
36
+
37
+## Lifecycle
38
+
39
+### 1. Start sesiune
40
+
41
+```swift
42
+let session = meter.startChargeRecord(for: device)
43
+// ⟹ ChargeRecord(
44
+//    id: UUID(),
45
+//    sessionID: UUID(),
46
+//    startTime: Date(),
47
+//    chargedDeviceID: device.id,
48
+//    meterID: meter.id,
49
+//    measurements: [],
50
+//    totalEnergy: 0
51
+// )
52
+```
53
+
54
+**MUST:**
55
+- `startTime` = `Date.now`
56
+- `sessionID` = UUID unic
57
+- `measurements` array = empty
58
+- `totalEnergy` = 0
59
+- Marchez sesiunea ca "active"
60
+
61
+**SHOULD:**
62
+- Notify UI: "Recording started"
63
+- Start periodic measurement requests (1Hz timer)
64
+- Log sesiune în analytics
65
+
66
+### 2. Record measurement
67
+
68
+```swift
69
+while session.isActive {
70
+    let measurement = meter.lastDataPoint
71
+    session.addMeasurement(measurement)
72
+}
73
+```
74
+
75
+**Frecvenţă**: 1Hz (1000ms interval)
76
+
77
+**MUST:**
78
+- `measurement.timestamp > session.startTime`
79
+- Măsurătorile ordonate cronologic
80
+- Ignore duplicate timestamps
81
+- Validare: voltage, current ≥ 0
82
+
83
+**SHOULD:**
84
+- Drop măsurători dacă queue > 500 items
85
+- Skip interval dacă meter nu are date nouă
86
+- Log invalid measurements (don't crash)
87
+
88
+**MAY:**
89
+- Adjust frecvenţă dacă battery low (500ms → 2s)
90
+- Throttle dacă temperatura critică
91
+
92
+### 3. End sesiune
93
+
94
+```swift
95
+meter.endChargeRecord(session)
96
+```
97
+
98
+**MUST:**
99
+- `endTime` = `Date.now`
100
+- Calculate `totalEnergy` = ∑(V × A × Δt) pentru fiecare interval
101
+- Call [Charge Curve Isolation](./ChargeCurveIsolation.md) pentru obţine valid range
102
+- Marchez sesiunea ca "completed"
103
+- Salvează în Core Data sync
104
+
105
+**SHOULD:**
106
+- Recalculează media power (watts)
107
+- Calculează duration (seconds)
108
+- Notify UI: "Recording completed"
109
+- Archive sesiunea (backup iCloud)
110
+
111
+**MAY:**
112
+- Trigger conflict resolution (dacă detecta duplicate-uri)
113
+- Trigger capacity learning (dacă sesiune completă)
114
+
115
+## API Public
116
+
117
+### Proprietăţi sesiune
118
+
119
+| Proprietate | Tip | Descriere |
120
+|---|---|---|
121
+| `id` | UUID | Identificator unic |
122
+| `sessionID` | UUID | Reference în persistență |
123
+| `chargedDeviceID` | UUID | Device monitorizat |
124
+| `meterID` | UUID | Meter folosit |
125
+| `startTime` | Date | Moment start |
126
+| `endTime` | Date? | Moment end (nil dacă active) |
127
+| `measurements` | [Measurement] | Puncte colectate |
128
+| `totalEnergy` | Double | Wh total |
129
+| `isActive` | Bool | Status curent |
130
+| `duration` | TimeInterval | end - start |
131
+| `peakPower` | Double | Max watts |
132
+| `averagePower` | Double | Mean watts |
133
+
134
+### Metode
135
+
136
+```swift
137
+// Orchestrare
138
+func startChargeRecord(for device: ChargedDevice) -> ChargeRecord
139
+func recordMeasurement(_ measurement: Measurement, in session: ChargeRecord)
140
+func endChargeRecord(_ session: ChargeRecord) -> ChargeRecord
141
+
142
+// Calculaţii
143
+func calculateTotalEnergy(measurements: [Measurement]) -> Double
144
+func calculatePeakPower(measurements: [Measurement]) -> Double
145
+func calculateAveragePower(measurements: [Measurement]) -> Double
146
+
147
+// Persistență
148
+func saveSession(_ record: ChargeRecord)
149
+func loadSession(id: UUID) -> ChargeRecord?
150
+```
151
+
152
+## Comportamente critice
153
+
154
+### Timeout pe sesiuni
155
+
156
+- **MUST**: Dacă nu primim măsurători > 5 min, sesiunea e considerat "stale"
157
+- **SHOULD**: Notifică utilizator: "No data received, disconnect?"
158
+- **MAY**: Auto-finalizează după 10 min inactivitate
159
+
160
+### Validare măsurători
161
+
162
+```swift
163
+func isValidMeasurement(_ m: Measurement) -> Bool {
164
+    // Voltage: 0V — 30V (USB + other)
165
+    // Current: 0A — 10A (typical chargers)
166
+    // Power: 0W — 300W (practical limit)
167
+    return m.voltage >= 0 && m.voltage <= 30 &&
168
+           m.current >= 0 && m.current <= 10 &&
169
+           m.power >= 0 && m.power <= 300
170
+}
171
+```
172
+
173
+**MUST**: Drop invalide measurements
174
+**SHOULD**: Log warnings pentru edge-case values
175
+
176
+### Merge duplicate sessions
177
+
178
+Se declanşează când:
179
+- Două sesiuni cu overlap temporal > 50%
180
+- Aceeaşi device + meter
181
+
182
+Rezoluţie:
183
+- **MUST**: Keep sesiunea cu mai multe măsurători
184
+- **MUST**: Combină energiile: `total = session1.energy + session2.energy`
185
+- **SHOULD**: Archive old session version
186
+
187
+### Energia negativă (edge case)
188
+
189
+Cauze posibile:
190
+1. Meter inversează polarity (rare)
191
+2. Măsurători corupte
192
+3. Bug în parsing
193
+
194
+**SHOULD**: Log warning
195
+**MAY**: Absolute value: `energy = abs(energy)`
196
+**NEVER**: Discard sesiune
197
+
198
+## Testare
199
+
200
+### Unit tests
201
+
202
+```swift
203
+// Start/end
204
+test_startSessionCreatesValidRecord()
205
+test_endSessionCalculatesTotalEnergy()
206
+
207
+// Measurements
208
+test_recordMeasurement_AddsToSession()
209
+test_recordMeasurement_FailsIfSessionEnded()
210
+test_measurementsAreOrdered()
211
+test_invalidMeasurement_IsDropped()
212
+
213
+// Calculations
214
+test_calculateTotalEnergy_WithValidData()
215
+test_calculateTotalEnergy_WithGaps()
216
+test_calculatePeakPower()
217
+test_calculateAveragePower()
218
+
219
+// Validation
220
+test_sessionTimeValidity()
221
+test_sessionDuration_Max48Hours()
222
+test_activeSessions_OnlyOne()
223
+```
224
+
225
+### Integration tests
226
+
227
+- [ ] Full cycle: start → record 100 samples → end
228
+- [ ] Sesiune pe device A, apoi device B (same meter)
229
+- [ ] Meter disconnect → auto-finalize
230
+- [ ] Energy = ∑ = match manual calculation
231
+- [ ] Sesiune persistent după app restart
232
+
233
+## Dependenţe
234
+
235
+- [Meter.md](./Meter.md): `meter.lastDataPoint`, state
236
+- [Charge Curve Isolation](./ChargeCurveIsolation.md): extract valid range
237
+- [Capacity Measurement](./CapacityMeasurement.md): capacity learning
238
+- [Charge Curve Storage](./ChargeCurveStorage.md): persistence
239
+- Core Data: `ChargeSession` entity
240
+
241
+## Notes
242
+
243
+- Time-series resolution: 1Hz (1000ms)
244
+- Energy formula: `E = ∑(V × A × Δt)` (integral numerical)
245
+- Legat: [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
+279 -0
Documentation/API Reference/CloudKitSync.md
@@ -0,0 +1,279 @@
1
+# CloudKit Sync
2
+
3
+Mecanismul de replicare a datelor către iCloud.
4
+
5
+## Arhitectură
6
+
7
+### Components
8
+
9
+1. **NSPersistentCloudKitContainer**: gestionează Core Data + CloudKit automat
10
+2. **CloudDeviceSettingsStore**: wrapper pe NSManagedObjectContext
11
+3. **Core Data Model**: `CKModel.xcdatamodeld` (curent: USB_Meter 2)
12
+4. **CloudKit Container**: `iCloud.ro.xdev.USB-Meter`
13
+
14
+### Model versioning
15
+
16
+```
17
+USB_Meter 1 (original)
18
+  ↓
19
+USB_Meter 2 (fix: removed uniqueness constraints)
20
+  ↓
21
+USB_Meter 3? (future)
22
+```
23
+
24
+**Curent**: USB_Meter 2 (schema v20)
25
+
26
+## Data Schema
27
+
28
+### DeviceSettings entity (Core Data)
29
+
30
+| Field | Type | Notes |
31
+|-------|------|-------|
32
+| `id` | UUID | Primary key |
33
+| `macAddress` | String | Optional (for migration) |
34
+| `meterName` | String | Chosen by user |
35
+| `tc66TemperatureUnit` | String | "celsius" / "fahrenheit" |
36
+| `createdAt` | Date | Immutable |
37
+| `updatedAt` | Date | Last sync timestamp |
38
+| `connectionMetadata` | JSON blob | Device, timestamp, expiry |
39
+| `discoveryMetadata` | JSON blob | Last seen, seen by |
40
+| `cloudKitRecordID` | String | Reference toward CloudKit |
41
+
42
+### Invarianţi
43
+
44
+- **MUST**: `macAddress` nu are `uniquenessConstraint` (not CloudKit safe)
45
+- **MUST**: Chiar dacă `macAddress` optional, duplicate entries ar trebui merged
46
+- **MUST**: `meterName` e unic per meter (no machine-generated names)
47
+- **MUST**: `updatedAt` se schimbă la fiecare sync
48
+- **SHOULD**: `connectionMetadata.expiresAt` = now() + 24h
49
+
50
+## Sync Lifecycle
51
+
52
+### Push (local → CloudKit)
53
+
54
+```
55
+User changes meterName
56
+  ↓
57
+AppData calls cloudStore.upsertDeviceSettings(...)
58
+  ↓
59
+NSManagedObjectContext saves
60
+  ↓
61
+NSPersistentCloudKitContainer auto-uploads to CloudKit
62
+  ↓
63
+CloudKit replica updated
64
+  ↓
65
+Other devices see change via NSPersistentCloudKitContainerEventChangeNotification
66
+```
67
+
68
+- **MUST**: Core Data save trebuie să se afle pe main thread
69
+- **MUST**: CloudKit sync e asincronă (fire-and-forget)
70
+- **SHOULD**: Notify UI după local save (don't wait for CloudKit)
71
+- **MAY**: Log upload errors
72
+
73
+### Pull (CloudKit → local)
74
+
75
+```
76
+iCloud change appears
77
+  ↓
78
+NSPersistentCloudKitContainer notifies
79
+  ↓
80
+AppData observes NSPersistentCloudKitContainerEventChangeNotification
81
+  ↓
82
+Merge remote change with local state
83
+  ↓
84
+Core Data context updated
85
+  ↓
86
+UI refreshes
87
+```
88
+
89
+- **MUST**: Merge strategy = "last write wins" (basate pe timestamp)
90
+- **MUST**: Conflict resolution e automat (NSMergeByPropertyObjectTrumpMergePolicy)
91
+- **SHOULD**: Loghează merge pentru debugging
92
+- **MAY**: Notifică user dacă conflict major
93
+
94
+### Conflict scenarios
95
+
96
+**Scenario 1**: Same meter renamed on two devices simultaneously
97
+```
98
+Device A: "Kitchen Meter" → "Main Meter"  (10:00:00)
99
+Device B: "Kitchen Meter" → "Lab Meter"   (10:00:05)
100
+```
101
+Resolution: Last timestamp wins → "Lab Meter" (Device B @ 10:00:05)
102
+
103
+**Scenario 2**: Duplicate entries cu aceeași MAC
104
+```
105
+Device A: macAddress = "AA:BB:CC:DD:EE:FF", meterName = "Meter 1"
106
+Device B (after restore): macAddress = "AA:BB:CC:DD:EE:FF", meterName = "Meter 1"
107
+```
108
+Resolution: Merge duplicate entries, keep one record (last write wins)
109
+
110
+## Discovery Throttling
111
+
112
+### Logică
113
+
114
+```swift
115
+func recordDiscovery(for macAddress: String) {
116
+    let lastSeen = discoveryMetadata[macAddress]?.lastSeen
117
+    let now = Date()
118
+    
119
+    if now.timeIntervalSince(lastSeen ?? .distantPast) >= 120 {
120
+        // OK: permit sync
121
+        cloudStore.recordDiscovery(macAddress, discoveredAt: now)
122
+    }
123
+    // else: skip (under throttle window)
124
+}
125
+```
126
+
127
+- **MUST**: Max 1 discovery record per device per 120s
128
+- **SHOULD**: Throttle pe app level (nu pe CloudKit)
129
+- **MUST**: Reseta timer dacă device disconnect-ează
130
+- **REASON**: Prevent CloudKit thrashing din repeat BT advertisements
131
+
132
+### Impact
133
+
134
+- BT scan at 0s, 30s, 60s, 90s, 120s, ... (periodc)
135
+- Doar descoperirile la 0s, 120s, 240s, ... sunt syncate la CloudKit
136
+- Alte descoperiri sunt cached local
137
+
138
+## Rebuild logic
139
+
140
+### Background
141
+
142
+Issue (March 2025):
143
+- Old `uniquenessConstraint` pe `macAddress` = incompatibil cu CloudKit
144
+- Old rebuild logic: delete ALL + recreate = delete storm în CloudKit
145
+- Old naming: `meterName` = platform model ("iPad") = irelevant pe CloudKit
146
+
147
+### Fix
148
+
149
+**Schema migration**: USB_Meter 1 → USB_Meter 2
150
+- Removed `uniquenessConstraint`
151
+- Made `macAddress` optional
152
+- Changed device name = hostname (all platforms)
153
+
154
+**Rebuild refactoring**: `rebuildCanonicalStoreIfNeeded()`
155
+```swift
156
+func rebuildCanonicalStoreIfNeeded(newVersion: Int) {
157
+    if cloudStoreRebuildVersion >= newVersion { return }
158
+    
159
+    // Update winner in-place, delete only duplicates
160
+    let groupedByMAC = groupEntries(by: \.macAddress)
161
+    for (mac, entries) in groupedByMAC {
162
+        guard entries.count > 1 else { continue }
163
+        let winner = entries.max(by: \.updatedAt)
164
+        let losers = entries.filter { $0.id != winner.id }
165
+        
166
+        // Delete losers only, keep winner
167
+        for loser in losers {
168
+            context.delete(loser)
169
+        }
170
+    }
171
+    
172
+    cloudStoreRebuildVersion = newVersion
173
+    try? context.save()
174
+}
175
+```
176
+
177
+- **MUST**: Update winner în-place (nu delete+recreate)
178
+- **MUST**: Delete doar duplicate entries (nu pe toti)
179
+- **MUST**: Set `cloudStoreRebuildVersion = 3` după fix
180
+- **REASON**: Prevent delete storms în CloudKit
181
+
182
+### Version history
183
+
184
+- **v1**: Original schema (cu uniqueness constraint)
185
+- **v2**: First rebuild (delete all + recreate) — DEPRECATED
186
+- **v3**: Correct rebuild (update in-place + delete duplicates only)
187
+
188
+## Legacy data migration
189
+
190
+### NSUbiquitousKeyValueStore (deprecated)
191
+
192
+```swift
193
+// Legacy stores
194
+let meterNames = NSUbiquitousKeyValueStore.default.dictionary(forKey: "MeterNames")
195
+// → {macAddress → meterName}
196
+
197
+let tempUnits = NSUbiquitousKeyValueStore.default.dictionary(forKey: "TC66TemperatureUnits")
198
+// → {macAddress → unit}
199
+```
200
+
201
+### Migration path
202
+
203
+1. Read from KV store
204
+2. For each MAC address:
205
+   ```swift
206
+   cloudStore.upsertDeviceSettings(
207
+       macAddress: mac,
208
+       meterName: meterNames[mac],
209
+       tc66TemperatureUnit: tempUnits[mac]
210
+   )
211
+   ```
212
+3. Mark as migrated în defaults
213
+
214
+- **MUST**: Menţine KV store pentru backward compat
215
+- **SHOULD**: Migrate pe startup dacă not migrated
216
+- **MAY**: Drop KV store la next major version
217
+
218
+## Testing
219
+
220
+### Unit tests
221
+
222
+```swift
223
+test_cloudStoreSaves_ToNSManagedObjectContext()
224
+test_upsertDeviceSettings_CreatesOrUpdates()
225
+test_discoveryThrottling_RespectsTiming()
226
+test_conflictResolution_LastWriteWins()
227
+test_rebuildCanonicalStore_UpdatesWinner_DeletesDuplicates()
228
+test_macAddressNoDuplicates_AfterRebuild()
229
+```
230
+
231
+### Integration tests
232
+
233
+- [ ] Multiple devices sync settings via CloudKit
234
+- [ ] Conflict detected and resolved
235
+- [ ] Rebuild v3 migrates old data correctly
236
+- [ ] Discovery throttling prevents CloudKit thrashing
237
+- [ ] Legacy KV store data migrates to Core Data
238
+
239
+## Error handling
240
+
241
+### Network errors
242
+
243
+```
244
+Error: Network unavailable
245
+→ Fail fast (don't retry immediately)
246
+→ Queue pending changes in Core Data
247
+→ Retry at next network change (observe URLSession events)
248
+```
249
+
250
+### CloudKit quota errors
251
+
252
+```
253
+Error: Quote exceeded (too many records)
254
+→ Log error
255
+→ Notify user: "iCloud storage full"
256
+→ Option: Archive old charge records
257
+```
258
+
259
+### Merge conflicts
260
+
261
+```
262
+Error: Conflict detected
263
+→ Auto-resolve via "last write wins"
264
+→ Log both versions for debugging
265
+→ Notify user if significant loss
266
+```
267
+
268
+## Dependencies
269
+
270
+- `NSPersistentCloudKitContainer`: from CloudKit framework
271
+- `CloudDeviceSettingsStore`: wrapper in AppData.swift
272
+- `CKModel.xcdatamodeld`: Core Data schema
273
+- `NSUbiquitousKeyValueStore`: legacy (deprecated)
274
+
275
+## References
276
+
277
+- Commit: [Fix CloudKit sync (Mar 2025)](https://github.com/...)
278
+- Documentaţie: [Project Memory - Fix CloudKit sync](../../MEMORY.md)
279
+- Issue: [[001 Catalyst TabView Freeze]]
+521 -0
Documentation/API Reference/ConsumptionMeasurement.md
@@ -0,0 +1,521 @@
1
+# Consumption Measurement Operation
2
+
3
+Măsurarea consumului pe o perioadă arbitrară, cu statistici şi predicţii bazate pe interval selectat.
4
+
5
+## Responsabilități
6
+
7
+- Măsurare consum liber (orice interval, nu doar sesiuni complete)
8
+- Afişare statistici pe interval selectat
9
+- Generare predicţii (extrapolări pe bază de trend)
10
+- Tăiere curbei ("tail trimming") pentru a selecta porţiunea relevantă
11
+- Salvează statisticile, NU datele măsurătorilor brute
12
+
13
+## Diferenţa faţă de Charging Monitoring
14
+
15
+| Aspect | Charging Monitoring | Consumption Measurement |
16
+|---|---|---|
17
+| Interval | Fixed (start → end charge) | Arbitrary (user-selected) |
18
+| Storage | Sesiune completă + measurements | Statistics only |
19
+| UI | Timeline sesiune | Graph with selection |
20
+| Predicţii | Capacity learning | Energy extrapolation |
21
+
22
+## Concepte
23
+
24
+### Consumption = Energy over time
25
+
26
+```
27
+Energy (Wh) = ∫ P(t) dt
28
+            = ∑ (V × A × Δt)
29
+
30
+Power (W) = V × A (instantaneous)
31
+Average Power (W) = Total Energy / Duration
32
+```
33
+
34
+## Invarianţi
35
+
36
+- **MUST**: `startTime < endTime` (interval valid)
37
+- **MUST**: Interval ≥ 10 secunde (minim useful)
38
+- **MUST**: Interval ≤ 30 zile (limit practic pentru extrapolări)
39
+- **MUST**: Statisticile trebuie salvate, măsurătorile brute se discard
40
+- **MUST**: Fără măsurători individuale în persistență, doar agregări
41
+- **SHOULD**: Interval tipic: 1 minut — 24 ore
42
+- **MAY**: Predicţii valide doar pe ±1 interval (ex: dacă measured 1h, predict next 1h)
43
+
44
+## Lifecycle
45
+
46
+### 1. Selection interval (user interaction)
47
+
48
+Utilizatorul deschide graficul şi selectează interval:
49
+
50
+```
51
+User taps start position în grafic
52
+User drags to end position
53
+System highlights selected region
54
+User taps "Measure consumption"
55
+```
56
+
57
+**Rezultat:**
58
+```swift
59
+struct ConsumptionSelection {
60
+    let startTime: Date
61
+    let endTime: Date
62
+    let meter: Meter
63
+    let device: ChargedDevice?  // Optional, may be unknown
64
+}
65
+```
66
+
67
+**MUST:** Times trebuie din măsurători existente (bounded)
68
+
69
+### 2. Compute statistics
70
+
71
+```swift
72
+func computeConsumptionStats(
73
+    measurements: [Measurement],
74
+    from startTime: Date,
75
+    to endTime: Date
76
+) -> ConsumptionStats {
77
+    // 1. Filter measurements în interval
78
+    let filtered = measurements.filter { m in
79
+        m.timestamp >= startTime && m.timestamp <= endTime
80
+    }
81
+    
82
+    guard !filtered.isEmpty else {
83
+        return .empty
84
+    }
85
+    
86
+    // 2. Calculate energy
87
+    let energy = calculateEnergy(filtered)
88
+    
89
+    // 3. Calculate time-based metrics
90
+    let duration = endTime.timeIntervalSince(startTime)
91
+    let averagePower = energy * 3600 / duration  // Convert Wh to W
92
+    
93
+    // 4. Power statistics
94
+    let powers = filtered.map { $0.power }
95
+    let peakPower = powers.max() ?? 0
96
+    let minPower = powers.min() ?? 0
97
+    let stdDev = calculateStdDev(powers)
98
+    
99
+    // 5. Voltage/Current stats
100
+    let voltages = filtered.map { $0.voltage }
101
+    let currents = filtered.map { $0.current }
102
+    
103
+    return ConsumptionStats(
104
+        energy: energy,
105
+        duration: duration,
106
+        averagePower: averagePower,
107
+        peakPower: peakPower,
108
+        minPower: minPower,
109
+        stdDevPower: stdDev,
110
+        sampleCount: filtered.count,
111
+        startVoltage: filtered.first?.voltage,
112
+        endVoltage: filtered.last?.voltage,
113
+        averageVoltage: voltages.average(),
114
+        averageCurrent: currents.average()
115
+    )
116
+}
117
+```
118
+
119
+### 3. Generate predictions
120
+
121
+```swift
122
+func predictFutureConsumption(
123
+    stats: ConsumptionStats,
124
+    duration: TimeInterval
125
+) -> ConsumptionPrediction {
126
+    // Linear extrapolation
127
+    let predictedEnergy = stats.averagePower * duration / 3600
128
+    
129
+    // Confidence bounds (±20% for stability)
130
+    let confidence = 0.8
131
+    let lowerBound = predictedEnergy * (1 - (1 - confidence))
132
+    let upperBound = predictedEnergy * (1 + (1 - confidence))
133
+    
134
+    return ConsumptionPrediction(
135
+        energy: predictedEnergy,
136
+        lowerBound: lowerBound,
137
+        upperBound: upperBound,
138
+        confidence: confidence,
139
+        method: "linear"
140
+    )
141
+}
142
+```
143
+
144
+**Metode:**
145
+- **Linear**: `E_predicted = P_avg × t` (simple, stable)
146
+- **Polynomial**: `E_predicted = a + b×t + c×t²` (for trends)
147
+- **Exponential**: (rarely used, for battery degradation)
148
+
149
+**MUST:** Confidence ≤ 1.0 (0-100%)
150
+**SHOULD:** Linear enough for 1 interval ahead
151
+**MAY:** Polynomial dacă > 2 intervals
152
+
153
+### 4. Tail trimming (user action)
154
+
155
+Utilizatorul poate "tăia coada" pentru a refina selecţia:
156
+
157
+```
158
+Initial selection: 00:00 - 10:00 (10h)
159
+User drags start: 00:30 - 10:00 (9.5h)
160
+User drags end: 00:30 - 09:30 (9h)
161
+Result: statistics recalculate pe [00:30, 09:30]
162
+```
163
+
164
+**Interactiv:**
165
+- Slider @ start time
166
+- Slider @ end time
167
+- Real-time stats update
168
+
169
+**MUST:** Recalculate stats on every trim
170
+**SHOULD:** Debounce updates (100ms)
171
+**MUST:** Store final selection, not intermediate states
172
+
173
+### 5. Save consumption record
174
+
175
+**NU salvează:** Măsurătorile individuale
176
+
177
+**SALVEAZĂ:**
178
+```swift
179
+struct ConsumptionRecord {
180
+    let id: UUID
181
+    let meterID: UUID
182
+    let deviceID: UUID?
183
+    let startTime: Date
184
+    let endTime: Date
185
+    
186
+    // Statistics (aggregated)
187
+    let totalEnergy: Double  // Wh
188
+    let averagePower: Double  // W
189
+    let peakPower: Double
190
+    let minPower: Double
191
+    let stdDevPower: Double
192
+    let sampleCount: Int
193
+    
194
+    // Metadata
195
+    let createdAt: Date
196
+    let notes: String?  // User annotation
197
+}
198
+```
199
+
200
+**NOT saved:**
201
+- ❌ Individual measurements (voltage, current per sample)
202
+- ❌ Timestamps of each sample
203
+- ❌ Raw power values
204
+
205
+**REASON:** Storage efficiency + privacy
206
+
207
+## API Public
208
+
209
+### Selection & Stats
210
+
211
+```swift
212
+// User selects interval from graph
213
+func selectConsumptionInterval(
214
+    start: Date,
215
+    end: Date,
216
+    meter: Meter
217
+) -> ConsumptionStats?
218
+
219
+// Refine selection via tail trimming
220
+func trimConsumptionInterval(
221
+    from newStart: Date,
222
+    to newEnd: Date
223
+) -> ConsumptionStats?
224
+
225
+// Get current statistics
226
+func currentStats() -> ConsumptionStats?
227
+```
228
+
229
+### Predictions
230
+
231
+```swift
232
+// Predict next interval
233
+func predictConsumption(
234
+    for duration: TimeInterval
235
+) -> ConsumptionPrediction?
236
+
237
+// Extrapolate to full day/week
238
+func extrapolateToDay() -> ConsumptionPrediction?
239
+func extrapolateToWeek() -> ConsumptionPrediction?
240
+```
241
+
242
+### Persistence
243
+
244
+```swift
245
+// Save aggregated stats only
246
+func saveConsumptionRecord(_ record: ConsumptionRecord) -> Bool
247
+
248
+// Load history
249
+func loadConsumptionRecords(for meter: Meter) -> [ConsumptionRecord]
250
+
251
+// Discard raw data (keep stats)
252
+func discardMeasurements(before: Date) -> Int
253
+```
254
+
255
+## Comportamente critice
256
+
257
+### Interval validation
258
+
259
+```
260
+User selects: 10:00 AM - 10:00 AM (zero duration)
261
+⟹ Error: "Minimum 10 seconds"
262
+
263
+User selects: 01-May - 01-June (31 days)
264
+⟹ Warning: "Extrapolation unreliable >7 days"
265
+```
266
+
267
+**MUST:** duration ≥ 10s
268
+**SHOULD:** Warn if duration > 7 days
269
+**SHOULD:** Cap predictions to ±1 interval
270
+
271
+### No tail = invalid
272
+
273
+```
274
+Selection: 10:00-11:00
275
+User never presses "Save"
276
+App terminates
277
+⟹ Selection lost (no persistence of in-progress)
278
+```
279
+
280
+**MUST:** NU salvează interim selections
281
+**SHOULD:** Keep in-memory during session
282
+**MUST:** Explicit save() required
283
+
284
+### Power variability
285
+
286
+```
287
+Measuring USB charger:
288
+Min power: 0.1W (idle)
289
+Max power: 18W (fast charge)
290
+StdDev: high (~5W)
291
+
292
+Prediction: linear avg = 8W
293
+Confidence: 0.7 (high variance)
294
+```
295
+
296
+**SHOULD:** Calculate stdDev
297
+**SHOULD:** Reflect confidence (lower if high variance)
298
+**MAY:** Suggest "Power unstable, prediction unreliable"
299
+
300
+### Measurement gaps
301
+
302
+```
303
+Recording: 10:00-11:00
304
+Meter disconnect: 10:30-10:45
305
+Data available: 10:00-10:30 (30min) + 10:45-11:00 (15min)
306
+User selects: 10:00-11:00 (but only 45min data)
307
+
308
+Stats: calculate on available data
309
+Warning: "15 minutes missing"
310
+```
311
+
312
+**MUST:** Alert user to gaps
313
+**SHOULD:** Calculate on available data only
314
+**MAY:** Adjust confidence (lower if gaps > 10%)
315
+
316
+## Statistici generate
317
+
318
+### Basic
319
+
320
+| Metric | Unit | Formula |
321
+|---|---|---|
322
+| Total Energy | Wh | ∑(V × A × Δt) |
323
+| Duration | seconds | endTime - startTime |
324
+| Average Power | W | Energy × 3600 / Duration |
325
+| Peak Power | W | max(P(t)) |
326
+| Min Power | W | min(P(t)) |
327
+
328
+### Advanced
329
+
330
+| Metric | Unit | Descriere |
331
+|---|---|---|
332
+| StdDev Power | W | Variabilitate în putere |
333
+| Avg Voltage | V | Media tensiune |
334
+| Avg Current | A | Media curent |
335
+| Sample Count | # | Measurement count |
336
+| Confidence | % | Reliability predicţie |
337
+
338
+## Predicţii
339
+
340
+### Linear extrapolation
341
+
342
+```
343
+Measured: P_avg = 10W over 1 hour
344
+Predict next 1h: E = 10W × 1h = 10Wh
345
+Bounds: ±(1-confidence)% = ±20% = [8Wh, 12Wh]
346
+```
347
+
348
+### Time scaling
349
+
350
+```
351
+Measured: 5W average over 10 minutes
352
+Predict over 1 hour: 5W × 6 = 30Wh
353
+Predict over 24h: 5W × 144 = 720Wh
354
+
355
+But confidence decreases with time:
356
+- 1×interval: 80% confidence
357
+- 2×interval: 60% confidence
358
+- 5×interval: 40% confidence
359
+- 10+×interval: 20% confidence
360
+```
361
+
362
+**MUST:** Confidence degrades dengan scaling
363
+**SHOULD:** Warn if confidence < 50%
364
+
365
+## Testare
366
+
367
+### Unit tests
368
+
369
+```swift
370
+test_selectConsumptionInterval_ValidDates()
371
+test_selectConsumptionInterval_MinimumDuration()
372
+test_trimConsumptionInterval_UpdatesStats()
373
+test_computeConsumptionStats_ValidData()
374
+test_computeConsumptionStats_EmptyRange()
375
+test_calculateAveragePower()
376
+test_predictConsumption_Linear()
377
+test_predictConsumption_ConfidenceDegrades()
378
+test_extrapolateToDay_WithinBounds()
379
+test_recordDoesNotStoreMeasurements()
380
+test_discardMeasurements_KeepsStats()
381
+```
382
+
383
+### Integration tests
384
+
385
+- [ ] Select 1h interval → get stats
386
+- [ ] Trim tail → stats recalculate
387
+- [ ] Predict next 1h → confidence 80%
388
+- [ ] Predict next 24h → confidence drops to 40%
389
+- [ ] Save record → measurements discarded, stats kept
390
+- [ ] Load record → only stats available
391
+- [ ] Measurement gap detected → warning shown
392
+- [ ] High power variance → confidence reduced
393
+
394
+## Edge cases
395
+
396
+### Very short interval
397
+
398
+```
399
+User selects: 10:00:00 - 10:00:05 (5 seconds)
400
+⟹ Error: below 10s minimum
401
+```
402
+
403
+**MUST:** Reject
404
+
405
+### Very long interval
406
+
407
+```
408
+User selects: 01-Jan - 31-Dec (365 days)
409
+⟹ Warning: "Extrapolation unreliable beyond 7 days"
410
+Confidence: 20%
411
+```
412
+
413
+**SHOULD:** Accept but warn
414
+**SHOULD:** Reduce confidence
415
+
416
+### Single measurement
417
+
418
+```
419
+Interval: 10:00:00 - 10:00:01
420
+Data points: 1 (only start)
421
+Energy: 0 (insufficient interval)
422
+⟹ Error: "Need minimum 10 measurements"
423
+```
424
+
425
+**MUST:** Require ≥ 10 samples
426
+
427
+### No voltage/current data
428
+
429
+```
430
+Meter type: Unknown or not reporting
431
+Power available: yes
432
+Voltage/Current: null
433
+
434
+Stats: calculate energy from power only
435
+Avg Voltage: null
436
+Avg Current: null
437
+```
438
+
439
+**SHOULD:** Handle gracefully
440
+**MAY:** Show "Limited data available"
441
+
442
+## Storage model
443
+
444
+### What IS saved
445
+
446
+```json
447
+{
448
+  "id": "UUID",
449
+  "meterID": "UUID",
450
+  "startTime": "2026-05-23T10:00:00Z",
451
+  "endTime": "2026-05-23T11:00:00Z",
452
+  "totalEnergy": 12.5,
453
+  "averagePower": 12.5,
454
+  "peakPower": 18.0,
455
+  "minPower": 1.2,
456
+  "stdDevPower": 4.5,
457
+  "sampleCount": 3600,
458
+  "createdAt": "2026-05-23T11:02:00Z"
459
+}
460
+```
461
+
462
+### What is NOT saved
463
+
464
+```
465
+❌ individual measurements
466
+❌ voltage samples
467
+❌ current samples  
468
+❌ power curve
469
+❌ battery levels
470
+❌ meter metadata
471
+```
472
+
473
+## Dependenţe
474
+
475
+- [Charging Monitoring](./ChargingMonitoring.md): access to meter data
476
+- [Charge Curve Storage](./ChargeCurveStorage.md): query full measurements
477
+- Core Data: ConsumptionRecord entity
478
+- UI: Graph view with interval selection
479
+
480
+## UI Components
481
+
482
+### Graph with selection
483
+
484
+```
485
+┌─────────────────────────────────┐
486
+│  Power (W)                      │
487
+│  20 ├──────┐                    │
488
+│     │ ████ │  ← user selection  │
489
+│  10 ├──████└────┐               │
490
+│     │          │               │
491
+│   0 └──────────┴───────────────┘
492
+│     10:00   10:30    11:00
493
+│     ↑start           ↑end
494
+│     (draggable)      (draggable)
495
+└─────────────────────────────────┘
496
+```
497
+
498
+### Stats display
499
+
500
+```
501
+Selected Interval: 10:00 — 10:30 (30 min)
502
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
503
+Energy:         6.3 Wh
504
+Average Power:  12.6 W
505
+Peak Power:     18.0 W
506
+Min Power:      1.2 W
507
+Variance:       ±4.5 W
508
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
509
+Prediction (next 30 min):
510
+  Energy:       6.3 Wh (confidence: 80%)
511
+  Range:        5.0 — 7.6 Wh
512
+```
513
+
514
+## Notes
515
+
516
+- **Not a charge session**: No device pairing, no capacity learning
517
+- **Independent operation**: Coexists with ChargeInsights
518
+- **Low storage footprint**: Only aggregates, not raw curves
519
+- **Privacy-friendly**: Discards detailed measurements
520
+- **Real-time**: Updates on interval selection/trimming
521
+- Related: [Charging Monitoring](./ChargingMonitoring.md) for full sessions
+590 -0
Documentation/API Reference/IdleConsumptionMeasurement.md
@@ -0,0 +1,590 @@
1
+# Idle Consumption Measurement Operation
2
+
3
+Măsurarea consumului rezidual (standby) al unui dispozitiv încărcat la maxim.
4
+
5
+## Responsabilități
6
+
7
+- Determinarea consumului inactiv real al unui dispozitiv (după 100% + top-up)
8
+- Detectare automată a stării "idle" (power stabilizat)
9
+- Selectare manuală a intervalului idle din grafic
10
+- Salvare profil idle per device
11
+- Utilizare pentru terminare automată sesiune + bilanț energetic
12
+
13
+## Context
14
+
15
+### Problema
16
+
17
+După ce dispozitivul raportează **100% battery**, încărcătorul nu se opreşte imediat:
18
+
19
+```
20
+Timeline:
21
+00:00 — Device reaches 100%
22
+00:00-00:15 — Top-up phase (unele devices încarcă semnificativ)
23
+               Current: 1.5A → 0.8A → 0.3A → 0.05A
24
+00:15 — Top-up terminat, device intra în idle
25
+00:15-∞ — Idle phase (consum rezidual constant)
26
+         Current: 0.05A constant
27
+         Power: V × 0.05A ≈ 0.25W
28
+```
29
+
30
+**Necesar:**
31
+- Distinge top-up phase de idle phase
32
+- Măsoară idle power real
33
+- Folosește-o pentru bilanț energetic final
34
+
35
+### Consumul idle = baseline pentru bilanț
36
+
37
+```
38
+Total energy from charger:      50 Wh (measured)
39
+Time charging:                  2 hours
40
+Idle power:                      0.25 W
41
+Energy to idle consumption:     0.25W × 2h = 0.5 Wh
42
+
43
+Real energy to device battery: 50 - 0.5 = 49.5 Wh
44
+
45
+⟹ Capacity learning mai precis
46
+⟹ Bilanț charger vs device vs loss
47
+```
48
+
49
+## Invarianţi
50
+
51
+- **MUST**: Device la 100% battery la start
52
+- **MUST**: Idle power < 5W (nu active usage)
53
+- **MUST**: Idle power > 0.01W (detectabil)
54
+- **MUST**: Idle interval ≥ 30 secunde (stabilitate)
55
+- **MUST**: Idle power salvat per (device, charger type)
56
+- **SHOULD**: Idle consumul constant (variance < 10%)
57
+- **MAY**: Idle profile salvat in Core Data
58
+
59
+## Lifecycle
60
+
61
+### 1. Setup & initialization
62
+
63
+Utilizatorul:
64
+1. Încarcă device la maxim (100%)
65
+2. Lasă conectat la charger după 100%
66
+3. Deschide app → "Measure idle consumption"
67
+4. Selectează dispozitiv + charger type
68
+5. Pornește măsurare
69
+
70
+```swift
71
+struct IdleConsumptionSession {
72
+    let id: UUID
73
+    let deviceID: UUID
74
+    let chargerType: ChargerType?
75
+    let startTime: Date
76
+    let measuredAt100Percent: Bool  // MUST: true
77
+    let sessionID: UUID
78
+}
79
+```
80
+
81
+**Precondition:**
82
+- Device must report 100% battery
83
+- Device connected to charger
84
+- Meter recording power
85
+
86
+### 2. Wait for top-up completion
87
+
88
+App observă power trend:
89
+
90
+```swift
91
+func detectIdlePhase(measurements: [Measurement]) -> Int? {
92
+    // Last 60 measurements (1 minute)
93
+    let recent = measurements.suffix(60)
94
+    
95
+    // Calculate power variance in recent window
96
+    let powers = recent.map { $0.power }
97
+    let mean = powers.reduce(0, +) / Double(powers.count)
98
+    let variance = powers.map { pow($0 - mean, 2) }.reduce(0, +) / Double(powers.count)
99
+    let stdDev = sqrt(variance)
100
+    
101
+    // Idle criteria:
102
+    // 1. Power < 5W (no active charging)
103
+    // 2. Variance < 10% of mean (stable)
104
+    // 3. Duration > 30s at this level
105
+    
106
+    if mean < 5.0 && stdDev < (mean * 0.1) {
107
+        return measurements.count - 60  // Index of phase start
108
+    }
109
+    return nil
110
+}
111
+```
112
+
113
+**Detection criteria:**
114
+- Power < 5W (no active charging)
115
+- StdDev < 10% of mean (stable, not fluctuating)
116
+- Duration > 30 seconds (confirm stability)
117
+
118
+**Timeline:**
119
+```
120
+10:00 — User starts measurement
121
+10:15 — Top-up ends, power drops to 0.25W
122
+        App auto-detects idle phase
123
+10:15-10:45 — User reviews, confirms
124
+```
125
+
126
+**MUST**: Detect automatically when possible
127
+**SHOULD**: Notify user "Idle phase detected @ 10:15"
128
+
129
+### 3. User review & selection
130
+
131
+User vede graficul cu highlight pentru idle phase detectată:
132
+
133
+```
134
+Power (W)
135
+20 ├───────────────────┐
136
+   │ Top-up phase      │
137
+10 ├───────────┐       │
138
+   │           │       │
139
+ 5 ├───────────┤       │
140
+   │  ↓ Idle detected   │
141
+ 0.5 ├───────────────┐ ◄─ Auto-selected interval
142
+   │                │
143
+   └────┬──────────┬──┴───────────→ time
144
+        10:15      10:45
145
+    (start idle)   (user confirmed)
146
+```
147
+
148
+**User actions:**
149
+- Tap to confirm auto-selected interval
150
+- Drag to refine start position
151
+- Drag to refine end position
152
+- Tap "Save idle profile"
153
+
154
+**MUST**: Allow manual override
155
+**SHOULD**: Offer auto-selection if confidence high
156
+
157
+### 4. Compute idle power
158
+
159
+```swift
160
+func computeIdlePower(
161
+    measurements: [Measurement],
162
+    from startTime: Date,
163
+    to endTime: Date
164
+) -> IdleConsumptionProfile {
165
+    let filtered = measurements.filter { m in
166
+        m.timestamp >= startTime && m.timestamp <= endTime
167
+    }
168
+    
169
+    guard !filtered.isEmpty else { return nil }
170
+    
171
+    let powers = filtered.map { $0.power }
172
+    let currents = filtered.map { $0.current }
173
+    let voltages = filtered.map { $0.voltage }
174
+    
175
+    let avgPower = powers.reduce(0, +) / Double(powers.count)
176
+    let stdDev = sqrt(
177
+        powers.map { pow($0 - avgPower, 2) }.reduce(0, +) / Double(powers.count)
178
+    )
179
+    
180
+    // Energy over idle period
181
+    let duration = endTime.timeIntervalSince(startTime)
182
+    let energy = avgPower * duration / 3600  // Wh
183
+    
184
+    return IdleConsumptionProfile(
185
+        deviceID: device.id,
186
+        chargerID: charger?.id,
187
+        idlePower: avgPower,  // W
188
+        stdDev: stdDev,
189
+        energy: energy,
190
+        sampleCount: filtered.count,
191
+        measuredAt: Date.now,
192
+        interval: (startTime, endTime)
193
+    )
194
+}
195
+```
196
+
197
+### 5. Save idle profile
198
+
199
+**Salvează:**
200
+```swift
201
+struct IdleConsumptionProfile {
202
+    let id: UUID
203
+    let deviceID: UUID
204
+    let chargerID: UUID?  // Optional, may be unknown charger
205
+    let idlePower: Double  // W, avg
206
+    let stdDev: Double     // W, variance
207
+    let energy: Double     // Wh, cumulative idle energy
208
+    let sampleCount: Int
209
+    let measuredAt: Date
210
+    let interval: (start: Date, end: Date)
211
+    let notes: String?
212
+}
213
+```
214
+
215
+**NU salvează:** Raw measurements
216
+
217
+**Storage:** Core Data `DeviceIdleProfile` table
218
+
219
+**MUST**: Salvare explicită (user confirm)
220
+**SHOULD**: Allow update (measure again if suspicious)
221
+
222
+## API Public
223
+
224
+### Measurement
225
+
226
+```swift
227
+// Start idle measurement session
228
+func startIdleConsumptionSession(
229
+    for device: ChargedDevice,
230
+    charger: ChargerType?
231
+) -> IdleConsumptionSession
232
+
233
+// Detect idle phase automatically
234
+func detectIdlePhase(
235
+    in measurements: [Measurement]
236
+) -> (startIndex: Int, confidence: Double)?
237
+
238
+// Compute idle profile for interval
239
+func computeIdlePower(
240
+    measurements: [Measurement],
241
+    from: Date,
242
+    to: Date
243
+) -> IdleConsumptionProfile?
244
+
245
+// Save profile
246
+func saveIdleProfile(_ profile: IdleConsumptionProfile) -> Bool
247
+```
248
+
249
+### Query & usage
250
+
251
+```swift
252
+// Get idle profile for device
253
+func loadIdleProfile(for device: ChargedDevice) -> IdleConsumptionProfile?
254
+
255
+// Get all profiles for device (multiple charger types)
256
+func loadIdleProfiles(for device: ChargedDevice) 
257
+    -> [IdleConsumptionProfile]
258
+
259
+// Get idle power estimate for device+charger combo
260
+func getIdlePower(
261
+    device: ChargedDevice,
262
+    charger: ChargerType?
263
+) -> Double?
264
+
265
+// Update profile after new measurement
266
+func updateIdleProfile(_ profile: IdleConsumptionProfile) -> Bool
267
+
268
+// Delete old profile
269
+func deleteIdleProfile(_ id: UUID) -> Bool
270
+```
271
+
272
+## Comportamente critice
273
+
274
+### Top-up phase confusion
275
+
276
+```
277
+Timeline misreading:
278
+User thinks 10:10 = idle start
279
+But device still trickling charge @ 0.2A
280
+Power = 1.0W (not idle yet!)
281
+⟹ False idle profile
282
+```
283
+
284
+**MUST**: Require variance < 10% (not just low power)
285
+**SHOULD**: Alert user if variance high
286
+**MAY**: Auto-suggest earlier/later boundary
287
+
288
+### Multiple charger types
289
+
290
+```
291
+Device A measured with:
292
+- Charger X: idle = 0.25W
293
+- Charger Y: idle = 0.30W (different charger)
294
+
295
+⟹ Store separate profiles
296
+⟹ User selects charger type at measurement time
297
+```
298
+
299
+**MUST**: Key by (device, charger type)
300
+**SHOULD**: Allow null charger (unknown)
301
+**SHOULD**: Group by device in UI
302
+
303
+### Device still charging (false idle)
304
+
305
+```
306
+User hits "save" while device still @ 100%
307
+But charger delivering 0.5A (top-up)
308
+Power = 2.5W
309
+⟹ This is NOT idle, it's slow charge!
310
+```
311
+
312
+**MUST**: Warn if battery % still 100% during interval
313
+**SHOULD**: Reject if power > 3W (too high for idle)
314
+**MAY**: Ask "Are you sure this is idle?"
315
+
316
+### Idle profile very high (> 1W)
317
+
318
+```
319
+Device idle measurement: 1.5W
320
+(Typical idle: 0.1-0.5W)
321
+⟹ Something unusual:
322
+   - Screen still on?
323
+   - Background app running?
324
+   - Device malfunctioning?
325
+```
326
+
327
+**SHOULD**: Warn "Idle power seems high"
328
+**SHOULD**: Suggest retry with device fully idle
329
+**MAY**: Store with `dataQuality: "warning"`
330
+
331
+## Use cases
332
+
333
+### 1. Auto-terminate charging session
334
+
335
+```
336
+Charging session started:
337
+  Start battery: 20%
338
+  Start energy: 0 Wh
339
+
340
+During charging:
341
+  Monitor power trend
342
+  When power drops to idle level...
343
+  
344
+Idle detection trigger:
345
+  Power < idle_threshold (e.g., 0.3W)
346
+  Duration > 2 minutes stable
347
+  Battery = 100%
348
+  
349
+⟹ Auto-terminate session
350
+⟹ Calculate final energy
351
+```
352
+
353
+**Pseudocode:**
354
+```swift
355
+func shouldAutoTerminateCharging(
356
+    currentPower: Double,
357
+    currentBattery: Double,
358
+    duration: TimeInterval
359
+) -> Bool {
360
+    let idleProfile = loadIdleProfile(for: device)
361
+    let idleThreshold = (idleProfile?.idlePower ?? 0.5) * 1.5  // 50% margin
362
+    
363
+    return currentPower < idleThreshold &&
364
+           currentBattery >= 99.0 &&
365
+           duration > 120  // 2 min stable
366
+}
367
+```
368
+
369
+### 2. Bilanț energetic final
370
+
371
+```
372
+Charging session:
373
+  Meter energy:      50.0 Wh
374
+  Duration:          2 hours
375
+  Idle power:        0.25 W
376
+  Idle energy:       0.25W × 2h = 0.5 Wh
377
+  
378
+Charger idle:        0.10 W
379
+Charger idle energy: 0.10W × 2h = 0.2 Wh
380
+
381
+Energy to device:    50.0 - 0.5 - 0.2 = 49.3 Wh
382
+```
383
+
384
+**Accuracy improvement**: +1-2% fidelity
385
+
386
+### 3. Anomaly detection
387
+
388
+```
389
+Device A (iPhone):
390
+  Normal idle: 0.15W
391
+  Today's measurement: 0.45W  (3× higher!)
392
+  
393
+⟹ Alert user:
394
+   "Device drawing 3× idle power"
395
+   "Battery drain issue?"
396
+   "Check background apps"
397
+```
398
+
399
+## Statistici generate
400
+
401
+| Metric | Unit | Descriere |
402
+|---|---|---|
403
+| Idle Power | W | Average power during idle |
404
+| StdDev | W | Variance (should be low) |
405
+| Idle Energy | Wh | Total over measurement period |
406
+| Sample Count | # | Measurements in interval |
407
+| Confidence | % | Stability metric |
408
+| Data Quality | flag | "good" / "warning" / "poor" |
409
+
410
+### Confidence calculation
411
+
412
+```swift
413
+func calculateConfidence(stdDev: Double, mean: Double) -> Double {
414
+    let relativeStdDev = stdDev / mean
415
+    
416
+    if relativeStdDev < 0.05 {
417
+        return 0.95  // Excellent
418
+    } else if relativeStdDev < 0.10 {
419
+        return 0.85  // Good
420
+    } else if relativeStdDev < 0.20 {
421
+        return 0.70  // Fair
422
+    } else {
423
+        return 0.50  // Poor (high variance)
424
+    }
425
+}
426
+```
427
+
428
+## Testare
429
+
430
+### Unit tests
431
+
432
+```swift
433
+test_detectIdlePhase_ValidData()
434
+test_detectIdlePhase_HighVariance_NotDetected()
435
+test_detectIdlePhase_HighPower_NotDetected()
436
+test_detectIdlePhase_MinimumDuration()
437
+test_computeIdlePower_ValidInterval()
438
+test_computeIdlePower_EmptyInterval()
439
+test_computeIdlePower_HighVariance_Warning()
440
+test_saveIdleProfile_Persists()
441
+test_loadIdleProfile_ByDevice()
442
+test_multipleChargerTypes_SeparateProfiles()
443
+test_shouldAutoTerminateCharging_WithIdleProfile()
444
+test_energyBilantz_SubtractsIdleEnergy()
445
+test_confidenceCalculation_RelativeVariance()
446
+```
447
+
448
+### Integration tests
449
+
450
+- [ ] Measure idle: device 100%, charger connected
451
+- [ ] Auto-detect idle phase when variance drops
452
+- [ ] User overrides auto-selection with manual interval
453
+- [ ] Save idle profile → reload → same values
454
+- [ ] Multiple profiles per device (different chargers)
455
+- [ ] Auto-terminate charging when idle detected
456
+- [ ] Energy bilanț matches meter - idle correction
457
+- [ ] Anomaly detection for 3× idle power
458
+
459
+## Edge cases
460
+
461
+### Very low idle (< 0.01W)
462
+
463
+```
464
+Device truly idle: 0.008W
465
+Below detection threshold
466
+⟹ Impossible to measure accurately
467
+```
468
+
469
+**SHOULD**: "Idle power too low to measure"
470
+**MAY**: Skip measurement
471
+
472
+### Device powered off during measurement
473
+
474
+```
475
+User powers off device mid-measurement
476
+Power drops to 0W
477
+⟹ Not idle, device is off
478
+```
479
+
480
+**MUST**: Detect power drop to 0
481
+**SHOULD**: Discard measurement
482
+**MAY**: Suggest "Check device is still on"
483
+
484
+### Charger unplugged mid-measurement
485
+
486
+```
487
+User unplugs charger
488
+Power drops immediately
489
+⟹ Not valid idle state
490
+```
491
+
492
+**SHOULD**: Detect sudden power drop
493
+**SHOULD**: Alert user "Charger disconnected?"
494
+
495
+### Very long idle measurement
496
+
497
+```
498
+User measures 24 hours of idle
499
+Device behavior may change:
500
+- OS updates background activity
501
+- Bluetooth periodic sync
502
+- GPS location update
503
+⟹ Not constant idle
504
+```
505
+
506
+**SHOULD**: Warn if > 2 hours
507
+**MAY**: Suggest shorter interval (30 min typical)
508
+
509
+## Storage & syncing
510
+
511
+### What IS saved
512
+
513
+```json
514
+{
515
+  "id": "UUID",
516
+  "deviceID": "UUID",
517
+  "chargerID": "UUID?",
518
+  "idlePower": 0.25,
519
+  "stdDev": 0.02,
520
+  "energy": 0.5,
521
+  "sampleCount": 3600,
522
+  "measuredAt": "2026-05-23T10:45:00Z",
523
+  "confidence": 0.85,
524
+  "dataQuality": "good"
525
+}
526
+```
527
+
528
+### What is NOT saved
529
+
530
+```
531
+❌ individual measurements
532
+❌ raw power samples
533
+❌ voltage/current timeseries
534
+❌ interval timestamps (only duration)
535
+```
536
+
537
+**Reason**: Storage efficiency
538
+
539
+### CloudKit sync
540
+
541
+- **SHOULD**: Sync idle profiles to iCloud
542
+- **REASON**: Use on other devices (same charger type)
543
+- **Example**: iPhone idle measured on device A, used on device B
544
+
545
+## Dependenţe
546
+
547
+- [Charging Monitoring](./ChargingMonitoring.md): session context
548
+- [Consumption Measurement](./ConsumptionMeasurement.md): power analysis
549
+- Core Data: `DeviceIdleProfile` entity
550
+- UI: Graph with selection + auto-detection highlight
551
+
552
+## Related features
553
+
554
+### Auto-terminate charging session
555
+
556
+```swift
557
+func shouldAutoTerminate(
558
+    session: ChargeSession,
559
+    currentPower: Double,
560
+    battery: Double
561
+) -> Bool {
562
+    guard battery >= 99.0 else { return false }
563
+    
564
+    let idle = loadIdleProfile(for: session.device)
565
+    let threshold = (idle?.idlePower ?? 0.5) * 1.5
566
+    
567
+    return currentPower < threshold
568
+}
569
+```
570
+
571
+### Energy balance correction
572
+
573
+```swift
574
+func correctedDeviceEnergy(
575
+    total: Double,
576
+    idleProfile: IdleConsumptionProfile,
577
+    duration: TimeInterval
578
+) -> Double {
579
+    let idleEnergy = idleProfile.idlePower * duration / 3600
580
+    return total - idleEnergy
581
+}
582
+```
583
+
584
+## Notes
585
+
586
+- **Measurement window**: Tipic 30 min — 2 ore
587
+- **Update frequency**: Every few weeks (device behavior stable)
588
+- **Per charger**: Different chargers may have different idle power
589
+- **Privacy**: No raw data, just aggregates
590
+- **Related**: [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
+152 -0
Documentation/API Reference/Meter.md
@@ -0,0 +1,152 @@
1
+# Meter Entity
2
+
3
+Reprezentarea unui contor Bluetooth conectat (UM25C, UM34C, TC66C).
4
+
5
+## Responsabilități
6
+
7
+- Modelarea unui dispozitiv USB power meter BT
8
+- Gestionarea stării de conectare şi comunicare
9
+- Citirea măsurătorilor de la dispozitiv
10
+- Persistență metadate conector (device, timestamp, expiry)
11
+
12
+## Invarianți
13
+
14
+- **MUST**: Un Meter are un `id` (UUID) unic în aplicaţie
15
+- **MUST**: Un Meter cu `macAddress` nu poate fi duplicat în Core Data (Cloud-Kit safe)
16
+- **MUST**: Starea `OperationalState` este monoton crescătoare: `notPresent → ... → dataIsAvailable`
17
+- **MUST**: Dacă `OperationalState` este `peripheralConnected` sau mai mare, trebuie să existe o conexiune BT activă
18
+- **SHOULD**: Un Meter inactiv (fără măsurători > 2 ore) ar trebui să se reconecteze automat
19
+- **MAY**: Temperature unit preference (Celsius/Fahrenheit) poate fi schimbat oricând
20
+
21
+## Estados operaţionale
22
+
23
+```
24
+notPresent
25
+  ↓
26
+peripheralNotConnected
27
+  ↓
28
+peripheralConnectionPending
29
+  ↓
30
+peripheralConnected
31
+  ↓
32
+peripheralReady
33
+  ↓
34
+comunicating ↔ dataIsAvailable
35
+```
36
+
37
+## API Public
38
+
39
+### Proprietăţi
40
+
41
+| Proprietate | Tip | Descriere | Observaţii |
42
+|---|---|---|---|
43
+| `id` | UUID | Identificator unic | Generat la creare |
44
+| `macAddress` | String | Adresa MAC BT | De ex: "AA:BB:CC:DD:EE:FF" |
45
+| `meter` | Model | Tipul: UM25C, UM34C, TC66C | Immutable după creare |
46
+| `meterName` | String | Nume ales de utilizator | Persistent în Core Data |
47
+| `operationalState` | OperationalState | Starea curentă | Published, triggers UI updates |
48
+| `lastDataPoint` | Measurement? | Ultima măsurătoare | Nullable |
49
+| `connectionMetadata` | ConnectionMetadata? | Info conexiune curentă | Device, timestamp, expiry |
50
+| `discoveryMetadata` | DiscoveryMetadata? | Info descoperire BT | Last seen, seen by |
51
+| `temperatureUnit` | TemperatureUnitPreference | Unitatei temperatură | Celsius/Fahrenheit |
52
+
53
+### Metode
54
+
55
+```swift
56
+// Conectare
57
+func connect() 
58
+// MUST: tranzițe starea la peripheralConnectionPending
59
+// SHOULD: se conectează în max 5s
60
+
61
+func disconnect()
62
+// MUST: tranzițe starea la peripheralNotConnected
63
+// MUST: eliberează resurse BT
64
+
65
+// Citire măsurători
66
+func requestMeasurement() -> Bool
67
+// SHOULD: retur true dacă cererea a fost trimisă
68
+// SHOULD: se așteaptă răspuns în max 3s (timeout)
69
+// MUST: nu trimite dacă starea este < comunicating
70
+
71
+func processMeasurementData(_ data: Data) -> Measurement?
72
+// Parsează payload-ul de la dispozitiv
73
+// MUST: retur nil dacă payload-ul e invalid
74
+// SHOULD: loghează checksum errors
75
+
76
+// Gestionare sesiuni
77
+func startChargeRecord(for device: ChargedDevice) -> ChargeRecord
78
+// Inițiază o nouă sesiune de încărcare
79
+
80
+func endChargeRecord(_ record: ChargeRecord)
81
+// Finalizează sesiunea
82
+
83
+// Naming
84
+func renameToMeter(_ newName: String)
85
+// MUST: actualizează proprietatea şi persistă în Core Data
86
+```
87
+
88
+## Comportamente critice
89
+
90
+### Reconexiune automată
91
+
92
+- **MUST**: Dacă `OperationalState < peripheralConnected`, retry-ează conectarea
93
+- **SHOULD**: Backoff exponential: 1s, 2s, 4s, 8s, max 60s
94
+- **SHOULD**: Anulează retry-urile dacă utilizatorul deconectează manual
95
+
96
+### Timeout pe măsurători
97
+
98
+- **MUST**: Dacă nu primim răspuns în 3s după cerere, timeout-ul măsurătorii
99
+- **SHOULD**: Loghează timeout-urile pentru debugging
100
+- **MAY**: Incrementează contorul de failed requests
101
+
102
+### Stare după disconnecţie accidentală
103
+
104
+- **MUST**: Dacă BT drop-ă accidental, starea revine la `peripheralNotConnected`
105
+- **SHOULD**: Încearcă reconexiune automată (backoff)
106
+- **MAY**: Notifică UI-ul cu banner "Meter disconnected"
107
+
108
+### Validare MAC address
109
+
110
+- **MUST**: MAC address trebuie să fie format valid (XX:XX:XX:XX:XX:XX)
111
+- **MUST**: Dacă MAC e invalid, Meter nu poate fi creat
112
+
113
+## Testare
114
+
115
+### Unit tests
116
+
117
+```swift
118
+// Stări operaţionale
119
+test_operationalStateTransition()
120
+test_stateMonotonicity()
121
+
122
+// Conectare
123
+test_connectInitiatesPeripheralConnectionPending()
124
+test_disconnectCleansUpResources()
125
+test_reconnectWithBackoff()
126
+
127
+// Măsurători
128
+test_requestMeasurementFailsIfStateInvalid()
129
+test_processMeasurementDataWithValidPayload()
130
+test_measurementTimeout()
131
+
132
+// Naming
133
+test_renameUpdatesPersistence()
134
+```
135
+
136
+### Integration tests
137
+
138
+- [ ] Meter apare în Sidebar după scan
139
+- [ ] Meter se reconectează după BT drop
140
+- [ ] Măsurătorile se salvează pentru ChargedDevice
141
+- [ ] Temperature unit e persistent între app restarts
142
+
143
+## Dependenţe
144
+
145
+- `BluetoothManager`: gestionează Core Bluetooth
146
+- `ChargeInsightsStore`: salvează măsurătorile
147
+- `AppData`: CloudKit sync
148
+
149
+## Note
150
+
151
+- Legat: [CloudKit Sync](./CloudKitSync.md) (persistență MAC, name)
152
+- Legat: [Bluetooth Discovery](./BluetoothDiscovery.md) (scan, advertisement)
+249 -0
Documentation/API Reference/Operations.md
@@ -0,0 +1,249 @@
1
+# Core Operations
2
+
3
+Operaţii principale care orchestrează aplicaţia.
4
+
5
+## AppData Lifecycle
6
+
7
+`AppData` este singleton-ul global care orchestrează toate subsistemele.
8
+
9
+### Inițializare
10
+
11
+```swift
12
+let appData = AppData()
13
+```
14
+
15
+- **MUST**: Se instanțiază în `AppDelegate.application(_:didFinishLaunchingWithOptions:)`
16
+- **MUST**: Se accesează din `SceneDelegate.scene(_:willConnectTo:options:)`
17
+- **MUST**: Inițializează `NSPersistentCloudKitContainer` cu name `"CKModel"`
18
+- **MUST**: Inițializează `CloudDeviceSettingsStore` (wraps NSManagedObjectContext)
19
+
20
+### Startup sequence
21
+
22
+1. `AppDelegate.application(_:didFinishLaunchingWithOptions:)`:
23
+   - Crează `AppData()`
24
+   - Inițializează Core Data stack
25
+   - Încarcă migrări din `cloudStoreRebuildVersion`
26
+
27
+2. `SceneDelegate.scene(_:willConnectTo:options:)`:
28
+   - Apelează `appData.activateCloudDeviceSync(context: sceneContext)`
29
+   - Inițiază BT scanning
30
+   - Inițiază CloudKit sync
31
+
32
+### Shutdown sequence
33
+
34
+- **SHOULD**: Finalizează sesiuni active la app terminate
35
+- **MUST**: Salvează Core Data context (`saveIfNeeded()`)
36
+- **SHOULD**: Deconectează BT graceful
37
+
38
+## Bluetooth Connection Management
39
+
40
+### Connection state machine
41
+
42
+```
43
+notPresent
44
+  ↓
45
+peripheralNotConnected ← (user disconnect)
46
+  ↓
47
+peripheralConnectionPending
48
+  ↓
49
+peripheralConnected
50
+  ↓
51
+peripheralReady
52
+  ↓
53
+comunicating ↔ dataIsAvailable
54
+```
55
+
56
+### Operations
57
+
58
+```swift
59
+// Conectare inițială
60
+blutooth.connect(to peripheralUUID: UUID, type: Model)
61
+// MUST: inițiază CBCentralManager scan
62
+// MUST: se conectează la UUID specific
63
+// SHOULD: timeout = 5s
64
+// MUST: pe succes, tranzițe la peripheralConnected
65
+
66
+// Deconectare
67
+bluetooth.disconnect(from meter: Meter)
68
+// MUST: anulează reconnect logic
69
+// MUST: eliberează resurse
70
+// MUST: marchez ca manual disconnect
71
+
72
+// Auto-reconnect
73
+bluetooth.autoReconnect(meter: Meter, backoff: Backoff)
74
+// SHOULD: exponential backoff: 1s, 2s, 4s, 8s, max 60s
75
+// MUST: anulează dacă utilizator disconnect manual
76
+// MUST: max 3 retry-uri consecutive
77
+```
78
+
79
+## Measurement Recording
80
+
81
+### Session lifecycle
82
+
83
+```swift
84
+let session = meter.startChargeRecord(for: device)
85
+```
86
+
87
+1. **Start**: Crează `ChargeRecord(sessionID: UUID(), startTime: now())`
88
+2. **Measure**: La fiecare 1-2s:
89
+   ```swift
90
+   let measurement = meter.lastDataPoint
91
+   session.addMeasurement(measurement)
92
+   ```
93
+3. **End**: 
94
+   ```swift
95
+   meter.endChargeRecord(session)
96
+   ```
97
+   - Calculează `totalEnergy = ∑(V * A * Δt)`
98
+   - Marchez ca `completed`
99
+   - Salvează în Core Data
100
+
101
+### Invarianţi
102
+
103
+- **MUST**: O sesiune activă per meter
104
+- **MUST**: Măsurătorile sunt cronologice
105
+- **MUST**: `startTime <= now() <= (endTime || ∞)`
106
+- **MUST**: Energia ≥ 0
107
+
108
+### Recording frequency
109
+
110
+- **SHOULD**: Măsurători la ~1Hz (1000ms interval)
111
+- **MAY**: Reduce frequency dacă battery low
112
+- **SHOULD**: Drop măsurători dacă queue > 100 items
113
+
114
+## Cloud Sync
115
+
116
+### Main concepts
117
+
118
+- **DeviceSettings**: entitate Core Data persistă MAC, name, temperature unit
119
+- **CloudDeviceSettingsStore**: wrapper pe NSManagedObjectContext
120
+- **Rebuild version**: `cloudStoreRebuildVersion` (curent: 3)
121
+
122
+### Sync flow
123
+
124
+1. **Upload**: Locale changes → Core Data → CloudKit
125
+   ```swift
126
+   cloudStore.upsertDeviceSettings(
127
+       macAddress: "AA:BB:CC:DD:EE:FF",
128
+       meterName: "Kitchen Meter",
129
+       tc66TemperatureUnit: .celsius,
130
+       connectionMetadata: ...
131
+   )
132
+   ```
133
+   - MUST: salvează în Core Data sync
134
+   - MUST: CloudKit container replica-ază automat
135
+
136
+2. **Download**: CloudKit changes → Core Data → UI
137
+   - **MUST**: NSPersistentCloudKitContainer sincronizează automat
138
+   - **SHOULD**: Refresh UI după fetch
139
+
140
+3. **Conflict resolution**: Dacă două modificări simultane
141
+   - **MUST**: Mergi pe "last write wins" dacă timestamps diferă
142
+   - **SHOULD**: Loghează conflictul pentru debugging
143
+
144
+### Discovery throttling
145
+
146
+- **MUST**: Max 1 discovery per device per 120s
147
+- **SHOULD**: Previne CloudKit thrashing din repeat BT advertisements
148
+- **MUST**: Reseta timer dacă device disconnect-ează
149
+
150
+## Device Settings Persistence
151
+
152
+### MAC address mapping
153
+
154
+```swift
155
+// CloudDeviceSettingsStore.swift
156
+func upsertDeviceSettings(
157
+    macAddress: String,
158
+    meterName: String,
159
+    tc66TemperatureUnit: TemperatureUnitPreference?,
160
+    connectionMetadata: ConnectionMetadata?,
161
+    discoveryMetadata: DiscoveryMetadata?
162
+)
163
+```
164
+
165
+- **MUST**: macAddress optional (datorită migration)
166
+- **MUST**: meterName unic pe meter (no duplicates)
167
+- **SHOULD**: connectionMetadata.expiresAt = now() + 24h
168
+
169
+### Legacy KV Store
170
+
171
+`NSUbiquitousKeyValueStore` sincronizează:
172
+- `MeterNames`: {macAddress → meterName}
173
+- `TC66TemperatureUnits`: {macAddress → unit}
174
+
175
+- **MUST**: Menţinere backward compat
176
+- **SHOULD**: Migrate pe Core Data la first sync
177
+- **MAY**: Drop la următoarea major version
178
+
179
+## Error handling
180
+
181
+### Bluetooth errors
182
+
183
+```
184
+Error: BT peripheral not found
185
+→ Retry connect cu backoff
186
+→ Max 3 retries, apoi fallback offline mode
187
+
188
+Error: Characteristic not found
189
+→ Log error
190
+→ Mark meter incompatible (UI warning)
191
+
192
+Error: Read timeout
193
+→ Retry measurement request
194
+→ Increment timeout counter
195
+```
196
+
197
+### CloudKit errors
198
+
199
+```
200
+Error: Network unavailable
201
+→ Queue pending changes
202
+→ Retry la next network change
203
+
204
+Error: Conflict detected
205
+→ Merge data (last write wins)
206
+→ Retry sync
207
+
208
+Error: Quota exceeded
209
+→ Log error
210
+→ Notify user (prune old data?)
211
+```
212
+
213
+## Testare
214
+
215
+### Unit tests
216
+
217
+```swift
218
+test_appDataInitializes_CoreDataAndCloudKit()
219
+test_bluetoothConnectInitiatesProperStateTransition()
220
+test_bluetoothDisconnect_CleansUp()
221
+test_autoReconnectBackoff_ExponentialScaling()
222
+test_sessionStartCreatesValidRecord()
223
+test_sessionEndCalculatesEnergy()
224
+test_cloudSyncUpsert_SavesToCoreData()
225
+test_discoveryThrottling_RespectsTiming()
226
+test_conflictResolution_LastWriteWins()
227
+```
228
+
229
+### Integration tests
230
+
231
+- [ ] Full app startup (BT + CloudKit)
232
+- [ ] Connect meter → start session → record measurements → end session
233
+- [ ] Disconnect meter → reconnect avec backoff
234
+- [ ] Device settings sync CloudKit → other device
235
+- [ ] Offline mode (queue changes, sync later)
236
+
237
+## Dependenţe
238
+
239
+- `AppData`: orchestrează tot
240
+- `BluetoothManager`: gestionează Core Bluetooth
241
+- `CloudDeviceSettingsStore`: Core Data + CloudKit
242
+- `ChargeInsightsStore`: sesiuni persistente
243
+- `ConsumptionMonitorStore`: monitorizare consum
244
+
245
+## Notes
246
+
247
+- Legat: [CloudKit Sync](./CloudKitSync.md)
248
+- Legat: [Bluetooth Discovery](./BluetoothDiscovery.md)
249
+- Referință: AppDelegate.swift, SceneDelegate.swift
+163 -0
Documentation/API Reference/Powerbank.md
@@ -0,0 +1,163 @@
1
+# Powerbank Entity
2
+
3
+Reprezentarea unei baterii externe (sursă de energie portabilă).
4
+
5
+## Responsabilități
6
+
7
+- Modelarea unei surse portabile de putere
8
+- Gestionarea sesiunilor de descărcare
9
+- Urmărirea stării de încărcare
10
+- Detectarea şi merge-ul duplicate-ilor
11
+
12
+## Invarianţi
13
+
14
+- **MUST**: Fiecare Powerbank are un `id` (UUID) unic
15
+- **MUST**: Capacity (mAh) trebuie să fie pozitiv
16
+- **MUST**: Un Powerbank poate avea multiple sesiuni de descărcare (neordonate pe timp)
17
+- **MUST**: Sesiunile sunt asociate unui Meter specific
18
+- **SHOULD**: Powerbank-ul cu 0 sesiuni ar trebui marcat pentru curatare
19
+- **MUST**: Nu pot exista duplicate fizice pe același MAC address fără merge explicare
20
+
21
+## Categoriile
22
+
23
+Un Powerbank este o subcategorie a `ChargedDevice` cu `class = .powerbank`.
24
+
25
+## API Public
26
+
27
+### Proprietăţi
28
+
29
+| Proprietate | Tip | Descriere | Observaţii |
30
+|---|---|---|---|
31
+| `id` | UUID | Identificator unic | Generat la criere |
32
+| `name` | String | Nume (ex: "Anker 10000mAh") | Ales de utilizator |
33
+| `macAddress` | String | Adresa BT | Pentru identificare unică |
34
+| `capacity` | Int | Capacitate în mAh | Trebuie să fie > 0 |
35
+| `dischargeSessions` | [DischargeSession] | Sesiunile de descărcare | Cronologice |
36
+| `totalEnergyDelivered` | Double | Total Wh furnizat | Calculat din sesiuni |
37
+| `createdAt` | Date | Data creării | Immutable |
38
+| `lastDischargedAt` | Date? | Data ultimei sesiuni | Nullable |
39
+
40
+### Metode
41
+
42
+```swift
43
+// Gestionare sesiuni
44
+func startDischargeSession(meter: Meter) -> DischargeSession
45
+// MUST: sessionID = UUID()
46
+// MUST: startTime = now()
47
+// MUST: retur DischargeSession cu status "active"
48
+// SHOULD: capacity nu scade (immutable)
49
+
50
+func recordDischargePoint(_ measurement: Measurement, in session: DischargeSession)
51
+// Înregistrează o măsurătoare de descărcare
52
+// MUST: measurement.timestamp > session.startTime
53
+// MUST: nu merge dacă sesiunea e completată
54
+
55
+func endDischargeSession(_ session: DischargeSession)
56
+// MUST: endTime = now()
57
+// MUST: calculează energie furnizată = ∑(V * A * Δt)
58
+// MUST: marchez sesiunea ca "completed"
59
+
60
+func recalculateTotalEnergy()
61
+// Recalculează totalul de Wh furnizat
62
+// SHOULD: se apelează după merge
63
+
64
+// Duplicate handling
65
+func mergeWith(_ other: Powerbank)
66
+// MUST: combină toate sesiunile din ambele PB-uri
67
+// MUST: elimină duplicate-urile de sesiuni
68
+// MUST: recalculează totalul
69
+// SHOULD: logs operaţia
70
+// MUST: marchez celalalt Powerbank ca "merged"
71
+
72
+// Naming
73
+func rename(to newName: String)
74
+// MUST: actualizează name property
75
+// MUST: persistă în Core Data
76
+```
77
+
78
+## Comportamente critice
79
+
80
+### Sesiuni de descărcare
81
+
82
+- **MUST**: Doar o sesiune activă per Powerbank la orice moment
83
+- **MUST**: Sesiunile completate nu pot fi modificate
84
+- **SHOULD**: Sesiunile care durează > 48h sunt marcate cu warning (posibil inactive)
85
+- **MAY**: Sesiuni orfane (meter deconectat > 2h) pot fi finalizate automat
86
+
87
+### Calculul energiei
88
+
89
+- **MUST**: Energie furnizată = ∑(V_out * A_out * Δt) pentru intervale
90
+- **MUST**: Unitatea = Wh (Watt-hours)
91
+- **MUST**: Trebuie să fie ne-negativă (energia nu poate fi absorbi-u înapoi)
92
+- **SHOULD**: Loghează warning dacă energia e negativă (date inconsistente)
93
+
94
+### Duplicate detection și merge
95
+
96
+Se declanşează când:
97
+1. Două Powerbanks cu același MAC address
98
+2. Două Powerbanks cu sesiuni suprapuse pe aceeași perioadă
99
+
100
+Rezoluţie:
101
+- **MUST**: Sesiunea cu mai multe măsurători = "winner"
102
+- **MUST**: Energiile sunt adunate (∑)
103
+- **MUST**: Sesiunile sunt deduplicate după sessionID
104
+- **SHOULD**: Old Powerbank e marcat ca "merged"
105
+- **MAY**: Notifică utilizator
106
+
107
+### State after merge
108
+
109
+- **MUST**: După merge, alte referințe la old Powerbank trebuie actualizate
110
+- **MUST**: Sessionuri din old PB redirecţionează la new PB
111
+- **SHOULD**: Old PB e marcat ReadOnly sau Hidden
112
+
113
+## Testare
114
+
115
+### Unit tests
116
+
117
+```swift
118
+// Sesiuni
119
+test_startSessionCreatesValidRecord()
120
+test_endSessionCalculatesEnergy()
121
+test_activeSessions_OnlyOne()
122
+
123
+// Măsurători
124
+test_recordPointFailsIfSessionEnded()
125
+test_measurementOrdering()
126
+
127
+// Merge
128
+test_mergeDetectsDuplicates()
129
+test_mergeKeepsMoreCompleteData()
130
+test_mergeRecalculatesTotalEnergy()
131
+test_mergeMarksOldAsMerged()
132
+
133
+// Naming
134
+test_renameUpdatesPersistence()
135
+
136
+// Validare
137
+test_capacityMustBePositive()
138
+test_energyCannotBeNegative()
139
+```
140
+
141
+### Integration tests
142
+
143
+- [ ] Powerbank apare după start sesiune
144
+- [ ] Sesiuni complete salvate în Core Data
145
+- [ ] CloudKit sync merge-ul corect
146
+- [ ] Energiile sunt consistente post-merge
147
+- [ ] Name changes persistent pe CloudKit
148
+- [ ] Capacity e immutable (nu se schimbă)
149
+
150
+## Dependenţe
151
+
152
+- `Meter`: pentru măsurători
153
+- `ChargeInsightsStore`: persistență sesiuni
154
+- `CloudKitSync`: replicare iCloud
155
+- `ChargedDevice`: Powerbank e subcategorie
156
+
157
+## Notes
158
+
159
+Documentaţie asoc:
160
+- [Powerbank Category](../Powerbank%20Category.md)
161
+- [Charge Session Integrity](../Charge%20Session%20Integrity%20and%20Conflict%20Healing.md)
162
+- **Status**: Adăugat recent (commit: 0772cad)
163
+- **Merge class+template**: plan în progress, schema v20
+105 -0
Documentation/API Reference/README.md
@@ -0,0 +1,105 @@
1
+# API Reference
2
+
3
+Această documentație defineşte comportamentul aşteptat al entităţilor principale şi operaţiilor aplicaţiei. Scopul este să prevenim regresia şi divergenţele în timp ce aplicaţia evoluează cu ajutorul agenţilor.
4
+
5
+## Entităţi principale
6
+
7
+1. **[Meter](./Meter.md)** - Contor Bluetooth (UM25C, UM34C, TC66C)
8
+   - Reprezentarea unui dispozitiv BT conectat
9
+   - Starea conectorului, metadatele descoperiri
10
+   - Operaţii: connect, disconnect, fetch measurements
11
+
12
+2. **[ChargedDevice](./ChargedDevice.md)** - Dispozitiv încărcat prin meter
13
+   - Modelul unui dispozitiv care se încarcă
14
+   - Sesiuni de încărcare, integritate date
15
+   - Operaţii: start session, record measurement, end session
16
+
17
+3. **[Powerbank](./Powerbank.md)** - Sursă de energie portabilă
18
+   - Modelul unei baterii externe
19
+   - Starea încărcării, sesiiuni de descărcare
20
+   - Operaţii: track discharge, merge duplicates
21
+
22
+## Operaţii core
23
+
24
+1. **[Charging Monitoring](./ChargingMonitoring.md)** - Monitorizarea unei sesiuni de încărcare
25
+   - Start → record measurements → end
26
+   - Colectare date la ~1Hz
27
+   - Validare măsurători, calculul energiei
28
+   - Timeout şi reconnect logic
29
+
30
+2. **[Capacity Measurement](./CapacityMeasurement.md)** - Măsurarea capacităţii bateriei
31
+   - Learning din complete discharge cycles
32
+   - Formula: Capacity = Energy / Nominal_Voltage
33
+   - Weighted moving average din sesiuni multiple
34
+   - Battery health tracking
35
+
36
+3. **[Charge Curve Isolation](./ChargeCurveIsolation.md)** - Izolarea curbei de încărcare
37
+   - Detectare real start (power threshold)
38
+   - Detectare real end (trickle charge)
39
+   - Eliminare zgomot pre/post-charge
40
+   - Combined method (power + battery fallback)
41
+
42
+4. **[Charge Curve Storage](./ChargeCurveStorage.md)** - Stocarea curbelor
43
+   - Persistență în Core Data
44
+   - Sincronizare CloudKit
45
+   - Compresie: downsampling, agregare
46
+   - Archive sesiuni vechi
47
+
48
+5. **[Consumption Measurement](./ConsumptionMeasurement.md)** - Măsurare consum arbitrar
49
+   - Interval liber, nu doar sesiuni complete
50
+   - Selectare din grafic + tail trimming
51
+   - Statistici: energy, power, variance
52
+   - Predicţii cu confidence bounds
53
+   - Salvează stats, NU măsurători brute
54
+
55
+6. **[Idle Consumption Measurement](./IdleConsumptionMeasurement.md)** - Măsurare consum inactiv
56
+   - Device la 100% + după top-up
57
+   - Auto-detect idle phase (power stabilizat)
58
+   - Selection pe grafic pentru refine
59
+   - Profil idle per (device, charger type)
60
+   - Folosit pentru auto-terminate şi bilanț energetic
61
+   - Corectare energie: total - idle_energy
62
+
63
+7. **[CloudKit Sync](./CloudKitSync.md)** - Sincronizare date în CloudKit
64
+   - Mecanismul de replicare către iCloud
65
+   - Conflictele şi rezoluţii
66
+   - Rebuild logic şi versioning
67
+
68
+8. **[Bluetooth Discovery](./BluetoothDiscovery.md)** - Descoperire dispozitive BT
69
+   - Scanning, throttling, caching
70
+   - Advertisement handling
71
+   - Device identification
72
+
73
+9. **[Core Operations](./Operations.md)** - Operaţii principale
74
+   - App lifecycle (AppData, SceneDelegate)
75
+   - Connection management
76
+   - State persistence
77
+
78
+## Convenţii
79
+
80
+- **MUST**: Comportament obligatoriu, nu se poate schimba fără update complet
81
+- **SHOULD**: Comportament aşteptat, regresia = bug
82
+- **MAY**: Comportament opţional, nu afectează integralitate
83
+
84
+## Model versioning
85
+
86
+- **Model versioning**: `cloudStoreRebuildVersion` în AppData
87
+- **Upgrade path**: UnitTests validează migrări la versionare nouă
88
+- **Backward compatibility**: Legacy sync via `NSUbiquitousKeyValueStore`
89
+
90
+## UI Alignment
91
+
92
+- **[UI_ALIGNMENT.md](./UI_ALIGNMENT.md)** — Mapare UI ↔ Documentație
93
+  - Status implementare per operație
94
+  - Needed UI changes untuk align cu docs
95
+  - Checklist alignment per priority
96
+
97
+---
98
+
99
+Fiecare fişier conţine:
100
+
101
+- Responsabilităţi (ce face această entitate)
102
+- Invarianţi (condiţii care trebuie să fie adevărate)
103
+- API public (metodele şi proprietăţile expuse)
104
+- Comportamente critice (cazuri speciale, edge cases)
105
+- Testare (cum validăm corectitudinea)
+473 -0
Documentation/API Reference/UI_ALIGNMENT.md
@@ -0,0 +1,473 @@
1
+# UI Alignment to API Documentation
2
+
3
+Mapare UI ↔ Operații documentate. Status implementare şi TODO-uri pentru a alinia UI la documentație.
4
+
5
+---
6
+
7
+## 1. Charging Monitoring
8
+
9
+**Documentație:** [ChargingMonitoring.md](./ChargingMonitoring.md)
10
+
11
+### Responsabilități
12
+- ✅ Start → record → end sesiune
13
+- ✅ Colectare măsurători ~1Hz
14
+- ✅ Calculul energiei totale
15
+- ⚠️ Timeout logic (incomplete)
16
+
17
+### UI Components
18
+
19
+| File | Status | Alignment | TODO |
20
+|---|---|---|---|
21
+| `MeterChargeRecordTabView.swift` | ✅ Exists | Good | Document timeout handling |
22
+| `ChargeRecordSheetView.swift` | ✅ Exists | Fair | Add measurement count UI |
23
+| `ChargeSessionCompletionSheetView.swift` | ✅ Exists | Fair | Show isolation interval |
24
+| `BatteryCheckpointEditorSheetView.swift` | ✅ Exists | Good | Add stdDev visualization |
25
+
26
+### Current UI Flow
27
+
28
+```
29
+MeterChargeRecordTabView
30
+├─ [Start recording button]
31
+├─ [Live power graph]
32
+├─ [Energy counter (Wh)]
33
+├─ [Duration counter]
34
+└─ [Stop recording button]
35
+   └─ ChargeRecordSheetView
36
+      ├─ [Completion details]
37
+      ├─ [Battery checkpoints]
38
+      ├─ [Charger selection]
39
+      └─ [Save/discard]
40
+```
41
+
42
+### Needed Alignments
43
+
44
+1. **Invariant validation display**
45
+   - MUST show error if start without device
46
+   - SHOULD show warning if session > 24h
47
+   - SHOULD show warning if measurement gap > 5min
48
+
49
+2. **Measurement monitoring**
50
+   - SHOULD display sample count (ex: "324 samples")
51
+   - SHOULD show last update time
52
+   - MAY show power trend (trending up/down/stable)
53
+
54
+3. **Isolation visualization**
55
+   - After stop, show isolated interval on curve
56
+   - Highlight [real_start, real_end]
57
+   - Show pre/post noise regions
58
+
59
+---
60
+
61
+## 2. Capacity Measurement
62
+
63
+**Documentație:** [CapacityMeasurement.md](./CapacityMeasurement.md)
64
+
65
+### Responsabilități
66
+- ✅ Learning din discharge sessions
67
+- ⚠️ Health tracking (not visible in UI)
68
+- ⚠️ Weighted average (calculated, not displayed)
69
+
70
+### UI Components
71
+
72
+| File | Status | Alignment | TODO |
73
+|---|---|---|---|
74
+| `ChargeSessionDetailView.swift` | ✅ Exists | Poor | Add capacity learning |
75
+| `ChargedDeviceSettingsView.swift` | ✅ Exists | Poor | Show measured vs rated capacity |
76
+| — | ❌ Missing | — | Battery health indicator |
77
+| — | ❌ Missing | — | Capacity trend graph |
78
+
79
+### Current UI
80
+
81
+```
82
+ChargeSessionDetailView
83
+├─ [Session info: date, duration, energy]
84
+├─ [Device/charger info]
85
+└─ [Checkpoint list]
86
+```
87
+
88
+### Needed Alignments
89
+
90
+1. **Capacity learning indicator**
91
+   - IF session = complete discharge (0% → 100%)
92
+     - Show: "Capacity learning: 2950 mAh ✓"
93
+   - ELSE
94
+     - Show: "Partial discharge (15%), no capacity learning"
95
+
96
+2. **Battery health display**
97
+   - NEW section: "Battery Health"
98
+     ```
99
+     Measured capacity:    2950 mAh
100
+     Rated capacity:       3000 mAh
101
+     Health:               98% ✓
102
+     ```
103
+   - IF health < 80%: Show warning "Aged battery"
104
+
105
+3. **Multiple measurement history**
106
+   - NEW tab: "Capacity History"
107
+   - Show last 5 discharge cycles
108
+   - Chart: capacity over time
109
+   - Show trend: "Stable" / "Degrading" / "Improving"
110
+
111
+---
112
+
113
+## 3. Charge Curve Isolation
114
+
115
+**Documentație:** [ChargeCurveIsolation.md](./ChargeCurveIsolation.md)
116
+
117
+### Responsabilități
118
+- ✅ Detect start (power threshold)
119
+- ✅ Detect end (trickle charge)
120
+- ⚠️ Visual feedback (weak)
121
+
122
+### UI Components
123
+
124
+| File | Status | Alignment | TODO |
125
+|---|---|---|---|
126
+| `ChargeRecordSheetView.swift` | ✅ Exists | Fair | Add isolation visualization |
127
+| `TimeSeriesChart.swift` | ✅ Exists | Good | Highlight isolated region |
128
+
129
+### Current UI
130
+
131
+```
132
+ChargeRecordSheetView
133
+├─ [Total energy]
134
+├─ [Duration]
135
+└─ [Checkpoints]
136
+```
137
+
138
+### Needed Alignments
139
+
140
+1. **Isolated interval visualization**
141
+   - After completion, show original curve with:
142
+     ```
143
+     Power (W)
144
+     20├──────────────────────
145
+       │ NOISE (discarded)
146
+     10├──────┐
147
+       │ ISOLATED REGION ████████
148
+      5├──────┘
149
+       │ NOISE (discarded)
150
+     0└──────────────────────
151
+     ```
152
+
153
+2. **Detection feedback**
154
+   - Show: "Auto-isolated [10:15–10:45] (30 min)"
155
+   - If manual override: "Custom selection [10:16–10:43]"
156
+
157
+3. **Noise estimation**
158
+   - Show: "Pre-charge noise: 15% of total"
159
+   - Show: "Post-charge noise: 5% of total"
160
+
161
+---
162
+
163
+## 4. Charge Curve Storage
164
+
165
+**Documentație:** [ChargeCurveStorage.md](./ChargeCurveStorage.md)
166
+
167
+### Responsabilități
168
+- ✅ Persist to Core Data
169
+- ✅ CloudKit sync
170
+- ⚠️ Compression visible to user
171
+
172
+### UI Components
173
+
174
+| File | Status | Alignment | TODO |
175
+|---|---|---|---|
176
+| `ChargedDeviceSessionsView.swift` | ✅ Exists | Good | Show compression status |
177
+| `ChargeSessionDetailView.swift` | ✅ Exists | Good | Show sample count |
178
+
179
+### Current UI
180
+
181
+```
182
+ChargedDeviceSessionsView
183
+├─ [Session list (sorted by date)]
184
+└─ [Each row: date, duration, energy]
185
+```
186
+
187
+### Needed Alignments
188
+
189
+1. **Compression indicator**
190
+   - IF session < 7 days: "Full resolution (3600 samples)"
191
+   - ELSE IF session < 365 days: "Downsampled (360 samples)"
192
+   - ELSE: "Archived (metadata only)"
193
+
194
+2. **Sync status**
195
+   - NEW badge: ☁️ (synced) / ⏳ (pending) / ⚠️ (conflict)
196
+
197
+3. **Storage estimate**
198
+   - Footer: "Storage: 2.4 MB (on device) + 1.2 MB (iCloud)"
199
+
200
+---
201
+
202
+## 5. Consumption Measurement
203
+
204
+**Documentație:** [ConsumptionMeasurement.md](./ConsumptionMeasurement.md)
205
+
206
+### Responsabilități
207
+- ✅ Select arbitrary interval
208
+- ✅ Tail trimming (graph selection)
209
+- ✅ Display statistics
210
+- ⚠️ Predictions (weak implementation)
211
+- ✅ Discard measurements
212
+
213
+### UI Components
214
+
215
+| File | Status | Alignment | TODO |
216
+|---|---|---|---|
217
+| `ConsumptionMonitorView.swift` | ✅ Exists | Good | Strengthen predictions |
218
+| `TimeSeriesChart.swift` | ✅ Exists | Good | Add interval selection |
219
+
220
+### Current UI
221
+
222
+```
223
+ConsumptionMonitorView
224
+├─ [Meter selection]
225
+├─ [Active session card]
226
+├─ [Live metrics (Power, Energy, Time)]
227
+├─ [Aggregation duration selector (300s default)]
228
+├─ [Inventory list]
229
+└─ [Saved sessions]
230
+```
231
+
232
+### Needed Alignments
233
+
234
+1. **Tail trimming improvements**
235
+   - Current: "Stop" button
236
+   - NEW: Interactive graph with draggable endpoints
237
+   ```
238
+   Power (W)
239
+   20├──────────────────
240
+     │ ◄─ drag start
241
+     │  [selected region]
242
+   10├──────────────────► drag end
243
+     │
244
+   0 └──────────────────
245
+     10:00   10:30   11:00
246
+   ```
247
+
248
+2. **Prediction display**
249
+   - Current: Only shows "Aggregation duration" selector
250
+   - NEW: Add prediction section
251
+   ```
252
+   Measured (10:00–10:30): 12.5W avg
253
+   Predict next 30 min:     12.5Wh ±2.5Wh (80% confidence)
254
+   Predict next 24h:        300Wh ±120Wh (40% confidence ⚠️)
255
+   ```
256
+
257
+3. **Statistics card**
258
+   - Current: Only total energy + duration
259
+   - NEW: Add table
260
+   ```
261
+   Total Energy:     12.5 Wh
262
+   Avg Power:        25 W
263
+   Peak Power:       32 W
264
+   Min Power:        15 W
265
+   Variance (σ):     ±3.2 W
266
+   Measurement #:    1800 samples
267
+   ```
268
+
269
+4. **Measurement discard confirmation**
270
+   - Current: ✓ (button exists)
271
+   - NEW: Show "Discarding measurements, keeping stats..."
272
+
273
+---
274
+
275
+## 6. Idle Consumption Measurement
276
+
277
+**Documentație:** [IdleConsumptionMeasurement.md](./IdleConsumptionMeasurement.md)
278
+
279
+### Responsabilități
280
+- ❌ NOT implemented in UI
281
+- ❌ NO idle detection
282
+- ❌ NO idle profiles
283
+
284
+### Current State
285
+
286
+```
287
+Status: ❌ MISSING
288
+```
289
+
290
+### Required Implementation
291
+
292
+#### New Views
293
+
294
+1. **IdleConsumptionSetupView**
295
+   ```
296
+   ┌─────────────────────────────┐
297
+   │ Measure Idle Consumption    │
298
+   ├─────────────────────────────┤
299
+   │ Device:      [iPhone 15  ▼] │
300
+   │ Charger:     [USB-C 20W ▼] │
301
+   │ Battery:     [100% ✓]      │
302
+   ├─────────────────────────────┤
303
+   │  [Start measurement]        │
304
+   └─────────────────────────────┘
305
+   ```
306
+
307
+2. **IdleConsumptionMonitorView**
308
+   ```
309
+   ┌──────────────────────────────┐
310
+   │ Recording idle consumption...│
311
+   ├──────────────────────────────┤
312
+   │ Power (W)              5 W   │
313
+   │  Trend: ↘ (stabilizing)     │
314
+   │                              │
315
+   │ Time elapsed:      00:15:32  │
316
+   │                              │
317
+   │ Status:  ✓ Idle detected     │
318
+   │         (power < 5W, stable) │
319
+   │                              │
320
+   │  [Confirm]   [Discard]       │
321
+   └──────────────────────────────┘
322
+   ```
323
+
324
+3. **IdleConsumptionReviewView**
325
+   ```
326
+   ┌──────────────────────────────┐
327
+   │ Idle Profile (Adjust)        │
328
+   ├──────────────────────────────┤
329
+   │ Power (W)                    │
330
+   │ 5 ├─────────────────         │
331
+   │   │     ◄─ slide to adjust   │
332
+   │ 0 │ ████████████► idle       │
333
+   │   └────────────────────      │
334
+   │   10:15      10:45           │
335
+   │                              │
336
+   │ Idle Power:    0.25 W        │
337
+   │ Duration:      30 min        │
338
+   │ Confidence:    85% ✓         │
339
+   │                              │
340
+   │ [Save profile]   [Retry]     │
341
+   └──────────────────────────────┘
342
+   ```
343
+
344
+4. **DeviceIdleProfilesView**
345
+   ```
346
+   ┌──────────────────────────────┐
347
+   │ Idle Profiles                │
348
+   ├──────────────────────────────┤
349
+   │ iPhone 15:                   │
350
+   │  └─ USB-C 20W:     0.25 W   │
351
+   │  └─ MagSafe:       0.20 W   │
352
+   │                              │
353
+   │ iPad Pro:                    │
354
+   │  └─ USB-C 30W:     0.45 W   │
355
+   │                              │
356
+   │  [Measure new]   [Edit]      │
357
+   └──────────────────────────────┘
358
+   ```
359
+
360
+#### Integration Points
361
+
362
+1. **ChargeSessionCompletionSheetView**
363
+   - Add row: "Auto-terminate when: [idle power + 50%]"
364
+   - When user selects idle profile, pre-fill
365
+
366
+2. **MeterChargeRecordTabView**
367
+   - Show idle indicator when power drops below threshold
368
+   - "Idle detected, session can auto-terminate"
369
+
370
+3. **ChargedDeviceSettingsView**
371
+   - NEW section: "Idle Consumption Profile"
372
+   - Show measured idle power per charger
373
+   - Link to "Measure idle..."
374
+
375
+---
376
+
377
+## 7. System Core Operations
378
+
379
+**Documentație:**
380
+- [Operations.md](./Operations.md)
381
+- [CloudKitSync.md](./CloudKitSync.md)
382
+- [BluetoothDiscovery.md](./BluetoothDiscovery.md)
383
+
384
+### Status: ✅ Mostly implemented
385
+
386
+| Component | UI File | Status |
387
+|---|---|---|
388
+| BT Discovery | `SidebarMeterCardView.swift` | ✅ Good |
389
+| Connection | `MeterHomeTabView.swift` | ✅ Good |
390
+| CloudKit sync | (implicit) | ✅ Good |
391
+
392
+### Needed Alignments
393
+
394
+1. **CloudKit sync status display**
395
+   - Current: No visible indicator
396
+   - NEW: Badge in meter card "☁️ synced @ 10:45"
397
+
398
+2. **Connection retry display**
399
+   - Current: Just shows "Offline"
400
+   - NEW: "Offline (retry in 8s...)" or "Reconnecting..."
401
+
402
+3. **Discovery throttling indicator**
403
+   - MAY: Show "Last scan: 45s ago" in debug view
404
+
405
+---
406
+
407
+## Summary Table
408
+
409
+| Operation | Documentație | UI Status | Alignment | Priority |
410
+|---|---|---|---|---|
411
+| Charging Monitoring | ✅ | ✅ Impl | Fair | Medium |
412
+| Capacity Learning | ✅ | ⚠️ Partial | Poor | High |
413
+| Curve Isolation | ✅ | ✅ Impl | Fair | Medium |
414
+| Curve Storage | ✅ | ✅ Impl | Good | Low |
415
+| Consumption Meas. | ✅ | ✅ Impl | Good | Low |
416
+| **Idle Consumption** | ✅ | ❌ Missing | — | **High** |
417
+| Cloud Sync | ✅ | ✅ Impl | Good | Low |
418
+| BT Discovery | ✅ | ✅ Impl | Good | Low |
419
+
420
+---
421
+
422
+## Alignment Checklist
423
+
424
+### High Priority (implement next)
425
+
426
+- [ ] Idle Consumption Measurement (all 4 new views)
427
+- [ ] Capacity Learning display in ChargeSessionDetailView
428
+- [ ] Battery Health section in ChargedDeviceSettingsView
429
+- [ ] Curve Isolation visualization in ChargeRecordSheetView
430
+
431
+### Medium Priority (refine)
432
+
433
+- [ ] Consumption prediction UI (confidence bounds)
434
+- [ ] Measurement validation warnings
435
+- [ ] Compression status indicators
436
+
437
+### Low Priority (nice-to-have)
438
+
439
+- [ ] Discovery throttling display
440
+- [ ] Retry countdown in connection status
441
+- [ ] Storage estimate footer
442
+
443
+---
444
+
445
+## Implementation Guide
446
+
447
+### For each operation:
448
+
449
+1. **Read documentation** (`ChargingMonitoring.md`, etc.)
450
+2. **Identify required UI** (sections above)
451
+3. **Check invariants** (MUST/SHOULD/MAY)
452
+4. **Implement/update views**
453
+5. **Add validation errors**
454
+6. **Test with unit tests**
455
+7. **Link to documentation in comments**
456
+
457
+Example comment in code:
458
+```swift
459
+// See: Documentation/API Reference/ChargingMonitoring.md
460
+// MUST: recordMeasurement fails if sessionState < active
461
+if !session.isActive {
462
+    throw SessionError.invalidState
463
+}
464
+```
465
+
466
+---
467
+
468
+## Notes
469
+
470
+- This document is a living checklist
471
+- Update as UI aligns with docs
472
+- Link each UI file to relevant docs
473
+- Mark `// DOCUMENTED` in code for traced invariants
+4 -0
Documentation/README.md
@@ -20,6 +20,8 @@ It is intended to keep the repository root focused on the app itself while prese
20 20
   Naming and file-organization rules for views, features, components, and subviews.
21 21
 - `External Contributions.md`
22 22
   Log of contributions from external collaborators, with technical evaluation per intervention.
23
+- `API Reference/`
24
+  Agent-facing specifications for entities, operations, invariants, UI alignment, storage, sync, and test expectations.
23 25
 - `Research Resources/`
24 26
   External source material plus the notes derived from it.
25 27
 
@@ -29,6 +31,8 @@ We keep two distinct layers of documentation:
29 31
 
30 32
 - project documentation
31 33
   Notes that describe our app, decisions, assumptions, and roadmap
34
+- API reference
35
+  Contract-style behavior specifications used to keep agents and contributors aligned with expected app behavior
32 36
 - research documentation
33 37
   Vendor manuals, software archives, contact sheets, protocol notes, and model-specific findings
34 38