@@ -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 |
|
@@ -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) |
|
@@ -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 |
|
@@ -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) |
|
@@ -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/ |
|
@@ -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) |
|
@@ -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) |
|
@@ -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]] |
|
@@ -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 |
|
@@ -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) |
|
@@ -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) |
|
@@ -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 |
|
@@ -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 |
|
@@ -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) |
|
@@ -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 |
|
@@ -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 |
|