USB-Meter / USB Meter / Views / Meter / Tabs / Live / ChargerStandbyPowerWizardView.swift
Newer Older
994 lines | 41.146kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargerStandbyPowerWizardView.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 13/04/2026.
6
//
7

            
8
import SwiftUI
Bogdan Timofte authored a month ago
9
import UniformTypeIdentifiers
Bogdan Timofte authored a month ago
10

            
11
struct ChargerStandbyPowerWizardView: View {
12
    @EnvironmentObject private var appData: AppData
13

            
14
    @State private var chargerLibraryVisibility = false
15
    @State private var discardConfirmationVisibility = false
16
    @State private var selectedMeterMACAddress: String?
17
    @State private var selectedChargerID: UUID?
18

            
19
    let preferredMeterMACAddress: String?
20
    let preferredChargerID: UUID?
21
    let locksChargerSelection: Bool
22

            
23
    init(
24
        preferredMeterMACAddress: String? = nil,
25
        preferredChargerID: UUID? = nil,
26
        locksChargerSelection: Bool = false
27
    ) {
28
        self.preferredMeterMACAddress = preferredMeterMACAddress
29
        self.preferredChargerID = preferredChargerID
30
        self.locksChargerSelection = locksChargerSelection
31
        _selectedMeterMACAddress = State(initialValue: nil)
32
        _selectedChargerID = State(initialValue: preferredChargerID)
33
    }
34

            
35
    var body: some View {
36
        ScrollView {
37
            VStack(spacing: 18) {
38
                if let session = activeSession {
39
                    activeMeasurementCard(session)
40
                    liveSessionCard(session)
41
                } else {
42
                    newMeasurementWizardCard
43
                }
44
            }
45
            .padding()
46
        }
47
        .background(
48
            LinearGradient(
49
                colors: [.orange.opacity(0.16), Color.clear],
50
                startPoint: .topLeading,
51
                endPoint: .bottomTrailing
52
            )
53
            .ignoresSafeArea()
54
        )
55
        .navigationTitle(navigationTitleText)
Bogdan Timofte authored a month ago
56
        .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
57
        .sheet(isPresented: $chargerLibraryVisibility) {
58
            ChargedDeviceLibrarySheetView(
59
                meterMACAddress: selectedMeterSummary?.macAddress ?? "",
60
                meterTint: selectedMeter?.color ?? .orange,
61
                mode: .charger
62
            )
63
            .environmentObject(appData)
64
        }
65
        .confirmationDialog(
66
            "Discard the current standby measurement?",
67
            isPresented: $discardConfirmationVisibility,
68
            titleVisibility: .visible
69
        ) {
70
            Button("Discard", role: .destructive) {
71
                if let activeSession {
72
                    _ = appData.finishChargerStandbyMeasurement(for: activeSession.meterMACAddress, save: false)
73
                }
74
            }
75
            Button("Cancel", role: .cancel) {}
76
        } message: {
77
            Text("The current sample set will be removed and nothing will be saved for this charger.")
78
        }
79
    }
80

            
81
    private var liveMeterSummaries: [AppData.MeterSummary] {
82
        appData.meterSummaries.filter { $0.meter != nil }
83
    }
84

            
85
    private var availableChargers: [ChargedDeviceSummary] {
86
        appData.chargerSummaries
87
    }
88

            
89
    private var preferredChargerMeterMACAddress: String? {
90
        preferredChargerID.flatMap { appData.chargedDeviceSummary(id: $0)?.lastAssociatedMeterMAC }
91
    }
92

            
93
    private var activeSession: ChargerStandbyPowerMonitorSession? {
94
        let candidateMACAddresses = [
95
            selectedMeterMACAddress ?? "",
96
            preferredMeterMACAddress ?? "",
97
            preferredChargerMeterMACAddress ?? ""
98
        ]
99
        .filter { $0.isEmpty == false }
100

            
101
        for macAddress in candidateMACAddresses {
102
            if let session = appData.chargerStandbyMeasurementSession(for: macAddress) {
103
                return session
104
            }
105
        }
106

            
107
        for meterSummary in liveMeterSummaries {
108
            if let session = appData.chargerStandbyMeasurementSession(for: meterSummary.macAddress) {
109
                return session
110
            }
111
        }
112

            
113
        return nil
114
    }
115

            
116
    private var suggestedMeterSummary: AppData.MeterSummary? {
117
        if let preferredMeterMACAddress {
118
            return liveMeterSummaries.first(where: { $0.macAddress == preferredMeterMACAddress })
119
        }
120

            
121
        if let preferredChargerMeterMACAddress {
122
            return liveMeterSummaries.first(where: { $0.macAddress == preferredChargerMeterMACAddress })
123
        }
124

            
125
        return liveMeterSummaries.count == 1 ? liveMeterSummaries.first : nil
126
    }
127

            
128
    private var selectedMeterSummary: AppData.MeterSummary? {
129
        if let activeSession {
130
            return liveMeterSummaries.first(where: { $0.macAddress == activeSession.meterMACAddress })
131
        }
132

            
133
        guard let selectedMeterMACAddress else {
134
            return nil
135
        }
136

            
137
        return liveMeterSummaries.first(where: { $0.macAddress == selectedMeterMACAddress })
138
    }
139

            
140
    private var selectedMeter: Meter? {
141
        selectedMeterSummary?.meter
142
    }
143

            
144
    private var isChargerSelectionLocked: Bool {
145
        locksChargerSelection || preferredChargerID != nil
146
    }
147

            
148
    private var meterSelectionBinding: Binding<String?> {
149
        Binding(
150
            get: { selectedMeterMACAddress },
151
            set: { selectedMeterMACAddress = $0 }
152
        )
153
    }
154

            
155
    private var selectedCharger: ChargedDeviceSummary? {
156
        if let activeSession {
157
            return appData.chargedDeviceSummary(id: activeSession.chargerID)
158
        }
159

            
160
        guard let selectedChargerID else {
161
            return nil
162
        }
163

            
164
        return appData.chargedDeviceSummary(id: selectedChargerID)
165
    }
166

            
167
    private var chargerSelectionBinding: Binding<UUID?> {
168
        Binding(
169
            get: { selectedChargerID },
170
            set: { selectedChargerID = $0 }
171
        )
172
    }
173

            
174
    private var preferredChargerSummary: ChargedDeviceSummary? {
175
        guard let preferredChargerID else {
176
            return nil
177
        }
178
        return appData.chargedDeviceSummary(id: preferredChargerID)
179
    }
180

            
181
    private var navigationTitleText: String {
182
        "New Standby Consumption Measurement"
183
    }
184

            
185
    private var wizardCardTitle: String {
186
        if let selectedMeterSummary {
187
            return selectedMeterSummary.displayName
188
        }
189

            
190
        if let suggestedMeterSummary {
191
            return suggestedMeterSummary.displayName
192
        }
193

            
194
        return "Use Meter"
195
    }
196

            
197
    private var newMeasurementWizardCard: some View {
198
        MeterInfoCardView(
199
            title: wizardCardTitle,
200
            tint: .orange
201
        ) {
202
            if liveMeterSummaries.isEmpty {
203
                Text("Connect a live meter first. Standby measurement uses a live feed, so meter selection happens here in the wizard.")
204
                    .font(.footnote)
205
                    .foregroundColor(.secondary)
206
                    .frame(maxWidth: .infinity, alignment: .leading)
207
            } else {
208
                VStack(alignment: .leading, spacing: 12) {
209
                    if isChargerSelectionLocked == false {
210
                        HStack(spacing: 8) {
211
                            Text("Charger")
212
                                .font(.subheadline.weight(.semibold))
213
                            ContextInfoButton(
214
                                title: "Charger",
215
                                message: "Choose the charger whose standby consumption you want to measure in this run."
216
                            )
217
                        }
218

            
219
                        if availableChargers.isEmpty {
220
                            Text("No charger available yet. Open the charger library to create one first.")
221
                                .font(.caption)
222
                                .foregroundColor(.secondary)
223
                        } else {
224
                            Picker("Charger", selection: chargerSelectionBinding) {
225
                                Text("Select Charger").tag(Optional<UUID>.none)
226
                                ForEach(availableChargers) { charger in
227
                                    Text(charger.name).tag(Optional(charger.id))
228
                                }
229
                            }
230
                            .pickerStyle(.menu)
231
                        }
232
                    }
233

            
234
                    HStack(spacing: 8) {
235
                        Text("Use Meter")
236
                            .font(.subheadline.weight(.semibold))
237
                        ContextInfoButton(
238
                            title: "Use Meter",
239
                            message: "Choose the live meter explicitly. Standby consumption can vary with the upstream source or when the charger is connected to a computer, so re-run after setup changes."
240
                        )
241
                    }
242

            
243
                    Picker("Use Meter", selection: meterSelectionBinding) {
244
                        Text("Select Meter").tag(Optional<String>.none)
245
                        ForEach(liveMeterSummaries) { meterSummary in
246
                            Text(meterSummary.displayName).tag(Optional(meterSummary.macAddress))
247
                        }
248
                    }
249
                    .pickerStyle(.menu)
250
                    .disabled(activeSession != nil)
251

            
252
                    if activeSession == nil, let suggestedMeterSummary, selectedMeterSummary == nil {
253
                        Text("Suggested from the current context: \(suggestedMeterSummary.displayName). Select it explicitly if this is the meter you want to use.")
254
                            .font(.caption)
255
                            .foregroundColor(.secondary)
256
                    }
257

            
258
                    HStack(spacing: 12) {
259
                        if isChargerSelectionLocked == false {
260
                            Button("Manage Charger Library") {
261
                                chargerLibraryVisibility = true
262
                            }
263
                            .disabled(selectedMeter == nil)
264
                        }
265

            
266
                        Button("Start Measurement") {
267
                            startMeasurement()
268
                        }
269
                        .disabled(selectedCharger == nil || selectedMeter == nil)
270
                    }
271
                    .buttonStyle(.borderedProminent)
272
                }
273
            }
274

            
275
            if selectedMeter == nil {
276
                Text("Choose the live meter explicitly before starting. The wizard no longer auto-confirms a suggested meter.")
277
                    .font(.caption)
278
                    .foregroundColor(.secondary)
279
            } else if activeSession == nil, selectedCharger == nil {
280
                Text("Select the charger you want to measure, then start the run.")
281
                    .font(.caption)
282
                    .foregroundColor(.secondary)
283
            } else if activeSession == nil, selectedMeter?.operationalState != .dataIsAvailable {
284
                Text("The wizard can start now, but samples will only be captured while live meter data is available.")
285
                    .font(.caption)
286
                    .foregroundColor(.secondary)
287
            } else if let activeSession {
288
                Text(
289
                    "\(activeSession.readinessDescription) • \(formattedDuration(Date().timeIntervalSince(activeSession.startedAt))) • \(activeSession.sampleCount) samples"
290
                )
291
                .font(.caption)
292
                .foregroundColor(.secondary)
293
            }
294
        }
295
    }
296

            
297
    private func activeMeasurementCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
298
        MeterInfoCardView(
299
            title: "Measurement Running",
300
            infoMessage: "The run keeps collecting samples while this meter stays live. Save when you are happy with the sample set, or discard to cancel it.",
301
            tint: .orange
302
        ) {
303
            MeterInfoRowView(label: "Meter", value: selectedMeterSummary?.displayName ?? session.meterMACAddress)
304
            MeterInfoRowView(label: "Charger", value: selectedCharger?.name ?? "Selected charger")
305
            MeterInfoRowView(label: "Status", value: session.readinessDescription)
306
            MeterInfoRowView(label: "Samples", value: "\(session.sampleCount)")
307

            
308
            HStack(spacing: 12) {
309
                Button("Save Result") {
310
                    _ = appData.finishChargerStandbyMeasurement(for: session.meterMACAddress, save: true)
311
                }
312
                .disabled(session.hasSamples == false)
313

            
314
                Button("Discard") {
315
                    discardConfirmationVisibility = true
316
                }
317
                .foregroundColor(.red)
318
            }
319
            .buttonStyle(.borderedProminent)
320
        }
321
    }
322

            
323
    private func liveSessionCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
324
        VStack(spacing: 18) {
325
            if let statistics = session.statistics {
326
                stabilityCard(
327
                    isStable: statistics.isStable,
328
                    averagePowerWatts: statistics.averagePowerWatts,
329
                    stabilityDeltaWatts: statistics.stabilityDeltaWatts,
330
                    stabilityToleranceWatts: statistics.stabilityToleranceWatts,
331
                    sampleCount: statistics.sampleCount
332
                )
333

            
334
                projectionCard(
335
                    averagePowerWatts: statistics.averagePowerWatts,
336
                    projectedDailyEnergyWh: statistics.projectedDailyEnergyWh,
337
                    projectedWeeklyEnergyWh: statistics.projectedWeeklyEnergyWh,
338
                    projectedMonthlyEnergyWh: statistics.projectedMonthlyEnergyWh,
339
                    projectedYearlyEnergyWh: statistics.projectedYearlyEnergyWh
340
                )
341

            
Bogdan Timofte authored a month ago
342
                StandbyPowerDistributionCard(
Bogdan Timofte authored a month ago
343
                    histogram: statistics.histogram,
344
                    averagePowerWatts: statistics.averagePowerWatts,
345
                    standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
346
                    tint: .orange
347
                )
348

            
349
                statisticsCard(
350
                    averagePowerWatts: statistics.averagePowerWatts,
351
                    medianPowerWatts: statistics.medianPowerWatts,
352
                    minimumPowerWatts: statistics.minimumPowerWatts,
353
                    maximumPowerWatts: statistics.maximumPowerWatts,
354
                    standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
355
                    coefficientOfVariation: statistics.coefficientOfVariation,
356
                    averageCurrentAmps: statistics.averageCurrentAmps,
357
                    averageVoltageVolts: statistics.averageVoltageVolts
358
                )
359
            } else {
360
                MeterInfoCardView(title: "Live Stats", tint: .orange) {
361
                    Text("Waiting for the first valid power samples from the meter.")
362
                        .font(.footnote)
363
                        .foregroundColor(.secondary)
364
                }
365
            }
366
        }
367
    }
368

            
369
    private func stabilityCard(
370
        isStable: Bool,
371
        averagePowerWatts: Double,
372
        stabilityDeltaWatts: Double,
373
        stabilityToleranceWatts: Double,
374
        sampleCount: Int,
375
        subtitle: String? = nil
376
    ) -> some View {
377
        VStack(alignment: .leading, spacing: 10) {
378
            HStack {
379
                VStack(alignment: .leading, spacing: 4) {
380
                    Text(isStable ? "Enough Samples" : "Still Settling")
381
                        .font(.headline)
382
                    Text(subtitle ?? (isStable ? "The running average has stabilised." : "The wizard is still watching the average drift."))
383
                        .font(.caption)
384
                        .foregroundColor(.secondary)
385
                }
386

            
387
                Spacer()
388

            
389
                Text(isStable ? "Ready" : "Live")
390
                    .font(.caption.weight(.semibold))
391
                    .padding(.horizontal, 10)
392
                    .padding(.vertical, 6)
393
                    .foregroundColor(isStable ? .green : .orange)
394
                    .meterCard(
395
                        tint: isStable ? .green : .orange,
396
                        fillOpacity: 0.10,
397
                        strokeOpacity: 0.16,
398
                        cornerRadius: 999
399
                    )
400
            }
401

            
402
            Text("\(averagePowerWatts.format(decimalDigits: 3)) W")
403
                .font(.system(.largeTitle, design: .rounded).weight(.bold))
404
                .monospacedDigit()
405

            
406
            Text(
407
                "Recent drift: \((stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(sampleCount) samples."
408
            )
409
            .font(.footnote)
410
            .foregroundColor(.secondary)
411
        }
412
        .frame(maxWidth: .infinity, alignment: .leading)
413
        .padding(18)
414
        .meterCard(tint: isStable ? .green : .orange, fillOpacity: 0.18, strokeOpacity: 0.24)
415
    }
416

            
417
    private func projectionCard(
418
        averagePowerWatts: Double,
419
        projectedDailyEnergyWh: Double,
420
        projectedWeeklyEnergyWh: Double,
421
        projectedMonthlyEnergyWh: Double,
422
        projectedYearlyEnergyWh: Double
423
    ) -> some View {
424
        MeterInfoCardView(
425
            title: "Consumption Projection",
426
            infoMessage: "These projections extrapolate only from the measured standby average. They do not say anything about charger behaviour under load.",
427
            tint: .teal
428
        ) {
429
            MeterInfoRowView(label: "Average Power", value: "\(averagePowerWatts.format(decimalDigits: 3)) W")
430
            MeterInfoRowView(label: "24 Hours", value: standbyEnergyLabel(projectedDailyEnergyWh))
431
            MeterInfoRowView(label: "7 Days", value: standbyEnergyLabel(projectedWeeklyEnergyWh))
432
            MeterInfoRowView(label: "30 Days", value: standbyEnergyLabel(projectedMonthlyEnergyWh))
433
            MeterInfoRowView(label: "1 Year", value: standbyEnergyLabel(projectedYearlyEnergyWh))
434
        }
435
    }
436

            
437
    private func statisticsCard(
438
        averagePowerWatts: Double,
439
        medianPowerWatts: Double,
440
        minimumPowerWatts: Double,
441
        maximumPowerWatts: Double,
442
        standardDeviationPowerWatts: Double,
443
        coefficientOfVariation: Double,
444
        averageCurrentAmps: Double,
445
        averageVoltageVolts: Double
446
    ) -> some View {
447
        MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
448
            MeterInfoRowView(label: "Median", value: "\(medianPowerWatts.format(decimalDigits: 3)) W")
449
            MeterInfoRowView(label: "Minimum", value: "\(minimumPowerWatts.format(decimalDigits: 3)) W")
450
            MeterInfoRowView(label: "Maximum", value: "\(maximumPowerWatts.format(decimalDigits: 3)) W")
451
            MeterInfoRowView(label: "Spread σ", value: "\(standardDeviationPowerWatts.format(decimalDigits: 4)) W")
452
            MeterInfoRowView(label: "Variation", value: "\(Int((coefficientOfVariation * 100).rounded()))%")
453
            MeterInfoRowView(label: "Mean Current", value: "\(averageCurrentAmps.format(decimalDigits: 3)) A")
454
            MeterInfoRowView(label: "Mean Voltage", value: "\(averageVoltageVolts.format(decimalDigits: 3)) V")
455
            MeterInfoRowView(label: "Power Density", value: "\(averagePowerWatts.format(decimalDigits: 3)) W steady")
456
        }
457
    }
458

            
459
    private func startMeasurement() {
460
        guard let selectedCharger, let selectedMeter else {
461
            return
462
        }
463

            
464
        _ = appData.startChargerStandbyMeasurement(for: selectedCharger.id, on: selectedMeter)
465
    }
466

            
467
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
468
        if wattHours >= 1000 {
469
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
470
        }
471
        return "\(wattHours.format(decimalDigits: 2)) Wh"
472
    }
473

            
474
    private func formattedDuration(_ duration: TimeInterval) -> String {
475
        let formatter = DateComponentsFormatter()
476
        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
477
        formatter.unitsStyle = .abbreviated
478
        formatter.zeroFormattingBehavior = .pad
479
        return formatter.string(from: max(duration, 0)) ?? "0s"
480
    }
481

            
482
}
483

            
Bogdan Timofte authored a month ago
484
// MARK: - Distribution card with resolution picker and CSV export
485

            
486
private struct StandbyPowerDistributionCard: View {
487
    let histogram: [ChargerStandbyPowerDistributionBin]
488
    let averagePowerWatts: Double
489
    let standardDeviationPowerWatts: Double
490
    let tint: Color
491
    var showExport: Bool = false
492

            
493
    private func resolution(for width: CGFloat) -> HistogramResolution {
494
        if width >= 600 { return .x4 }
495
        if width >= 360 { return .x2 }
496
        return .x1
497
    }
498

            
499
    private func displayedHistogram(width: CGFloat) -> [ChargerStandbyPowerDistributionBin] {
500
        let factor = HistogramResolution.x4.rawValue / resolution(for: width).rawValue
501
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(histogram, factor: factor)
502
    }
503

            
504
    private var csvString: String {
505
        var lines = ["Bin,Lower Bound (W),Upper Bound (W),Count,Relative Frequency (%)"]
506
        for bin in histogram {
507
            lines.append(
508
                "\(bin.index + 1),"
509
                + String(format: "%.6f", bin.lowerBoundWatts) + ","
510
                + String(format: "%.6f", bin.upperBoundWatts) + ","
511
                + "\(bin.count),"
512
                + String(format: "%.4f", bin.relativeFrequency * 100)
513
            )
514
        }
515
        return lines.joined(separator: "\n")
516
    }
517

            
518
    var body: some View {
519
        MeterInfoCardView(
520
            title: "Value Spread",
521
            infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and standard deviation.",
522
            tint: tint,
523
            trailingActions: {
524
                if showExport {
525
                    if #available(iOS 16, *) {
526
                        ShareLink(
527
                            item: DistributionCSVExport(content: csvString),
528
                            preview: SharePreview("distribution.csv")
529
                        ) {
530
                            Image(systemName: "square.and.arrow.up")
531
                                .font(.subheadline.weight(.medium))
532
                                .foregroundStyle(.secondary)
533
                        }
534
                    } else {
535
                        Button {
536
                            exportCSVLegacy(csvString)
537
                        } label: {
538
                            Image(systemName: "square.and.arrow.up")
539
                                .font(.subheadline.weight(.medium))
540
                                .foregroundStyle(.secondary)
541
                        }
542
                    }
543
                }
544
            }
545
        ) {
546
            GeometryReader { proxy in
547
                let bins = displayedHistogram(width: proxy.size.width)
548
                StandbyPowerHistogramView(
549
                    histogram: bins,
550
                    averagePowerWatts: averagePowerWatts,
551
                    standardDeviationPowerWatts: standardDeviationPowerWatts,
552
                    tint: tint
553
                )
554

            
555
                if let firstBin = bins.first, let lastBin = bins.last {
556
                    let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
557
                    VStack {
558
                        Spacer()
559
                        HStack {
560
                            Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
561
                            Spacer()
562
                            Text("\(midpointWatts.format(decimalDigits: 3)) W")
563
                            Spacer()
564
                            Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
565
                        }
566
                        .font(.caption)
567
                        .foregroundColor(.secondary)
568
                        .monospacedDigit()
569
                    }
570
                }
571
            }
572
            .frame(height: 240)
573
        }
574
    }
575

            
576
    private func exportCSVLegacy(_ csv: String) {
577
        guard let windowScene = UIApplication.shared.connectedScenes
578
            .compactMap({ $0 as? UIWindowScene }).first,
579
              let rootVC = windowScene.windows.first?.rootViewController else { return }
580
        let activityVC = UIActivityViewController(
581
            activityItems: [csv],
582
            applicationActivities: nil
583
        )
584
        rootVC.present(activityVC, animated: true)
585
    }
586
}
587

            
588
@available(iOS 16, *)
589
struct DistributionCSVExport: Transferable {
590
    let content: String
591

            
592
    static var transferRepresentation: some TransferRepresentation {
593
        DataRepresentation(exportedContentType: .commaSeparatedText) { export in
594
            Data(export.content.utf8)
595
        }
596
        .suggestedFileName("distribution")
597
    }
598
}
599

            
600
// MARK: - Histogram bars + Gaussian curve
601

            
Bogdan Timofte authored a month ago
602
private struct StandbyPowerHistogramView: View {
603
    let histogram: [ChargerStandbyPowerDistributionBin]
604
    let averagePowerWatts: Double
605
    let standardDeviationPowerWatts: Double
606
    let tint: Color
607

            
608
    var body: some View {
609
        GeometryReader { proxy in
610
            let maxCount = max(Double(histogram.map(\.count).max() ?? 1), 1)
611

            
612
            ZStack {
613
                HStack(alignment: .bottom, spacing: 6) {
614
                    ForEach(histogram) { bin in
615
                        RoundedRectangle(cornerRadius: 8, style: .continuous)
616
                            .fill(tint.opacity(0.24))
617
                            .overlay(
618
                                RoundedRectangle(cornerRadius: 8, style: .continuous)
619
                                    .stroke(tint.opacity(0.22), lineWidth: 1)
620
                            )
621
                            .frame(height: max(10, (Double(bin.count) / maxCount) * proxy.size.height))
622
                    }
623
                }
624
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
625

            
626
                gaussianCurve(in: proxy.size)
627
                    .stroke(tint, style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
628

            
629
                meanMarker(in: proxy.size)
630
                    .stroke(tint.opacity(0.9), style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]))
631
            }
632
        }
633
    }
634

            
635
    private func gaussianCurve(in size: CGSize) -> Path {
636
        guard histogram.count > 1,
637
              standardDeviationPowerWatts > 0,
638
              let firstBin = histogram.first,
639
              let lastBin = histogram.last else {
640
            return Path()
641
        }
642

            
643
        let minimum = firstBin.lowerBoundWatts
644
        let maximum = lastBin.upperBoundWatts
645
        let span = max(maximum - minimum, 0.000_001)
646
        let sampleCount = 48
647
        let peakDensity = 1 / (standardDeviationPowerWatts * sqrt(2 * .pi))
648

            
649
        return Path { path in
650
            for index in 0...sampleCount {
651
                let progress = Double(index) / Double(sampleCount)
652
                let value = minimum + (span * progress)
653
                let zScore = (value - averagePowerWatts) / standardDeviationPowerWatts
654
                let density = exp(-0.5 * zScore * zScore) / (standardDeviationPowerWatts * sqrt(2 * .pi))
655
                let normalizedHeight = density / peakDensity
656

            
657
                let x = progress * size.width
658
                let y = size.height - (normalizedHeight * (Double(size.height) * 0.92))
659
                let point = CGPoint(x: x, y: y)
660

            
661
                if index == 0 {
662
                    path.move(to: point)
663
                } else {
664
                    path.addLine(to: point)
665
                }
666
            }
667
        }
668
    }
669

            
670
    private func meanMarker(in size: CGSize) -> Path {
671
        guard let firstBin = histogram.first, let lastBin = histogram.last else {
672
            return Path()
673
        }
674

            
675
        let minimum = firstBin.lowerBoundWatts
676
        let maximum = lastBin.upperBoundWatts
677
        let span = max(maximum - minimum, 0.000_001)
678
        let normalizedX = min(max((averagePowerWatts - minimum) / span, 0), 1)
679
        let x = normalizedX * size.width
680

            
681
        return Path { path in
682
            path.move(to: CGPoint(x: x, y: 0))
683
            path.addLine(to: CGPoint(x: x, y: size.height))
684
        }
685
    }
686
}
687

            
688
struct ChargerStandbyPowerMeasurementsView: View {
689
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
690
    @State private var selectedMeasurementIDs = Set<UUID>()
Bogdan Timofte authored a month ago
691
    @State private var editMode: EditMode = .inactive
Bogdan Timofte authored a month ago
692

            
693
    let chargerID: UUID
694

            
695
    var body: some View {
696
        Group {
697
            if let charger = appData.chargedDeviceSummary(id: chargerID) {
Bogdan Timofte authored a month ago
698
                measurementsList(for: charger)
Bogdan Timofte authored a month ago
699
            } else {
700
                Text("This charger is no longer available.")
701
                    .foregroundColor(.secondary)
702
                    .navigationTitle("Saved Measurements")
Bogdan Timofte authored a month ago
703
                    .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
704
            }
705
        }
706
    }
707

            
Bogdan Timofte authored a month ago
708
    @ViewBuilder
709
    private func measurementsList(for charger: ChargedDeviceSummary) -> some View {
710
        let content = List(selection: $selectedMeasurementIDs) {
711
            if charger.standbyPowerMeasurements.isEmpty {
712
                Text("No standby measurements saved yet.")
713
                    .foregroundColor(.secondary)
714
            } else {
715
                ForEach(charger.standbyPowerMeasurements) { measurement in
716
                    NavigationLink(
717
                        destination: ChargerStandbyPowerMeasurementDetailView(
718
                            chargerID: charger.id,
719
                            measurementID: measurement.id
720
                        )
721
                    ) {
722
                        VStack(alignment: .leading, spacing: 6) {
723
                            HStack {
724
                                Text(measurement.endedAt.format())
725
                                    .font(.subheadline.weight(.semibold))
726
                                Spacer()
727
                                Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
728
                                    .font(.subheadline.weight(.bold))
729
                                    .monospacedDigit()
730
                            }
731

            
732
                            Text(
733
                                "\(formattedDuration(measurement.duration)) • \(measurement.sampleCount) samples • \(standbyEnergyLabel(measurement.projectedYearlyEnergyWh)) / year"
734
                            )
735
                            .font(.caption)
736
                            .foregroundColor(.secondary)
737
                        }
738
                        .frame(maxWidth: .infinity, alignment: .leading)
739
                    }
740
                    .tag(measurement.id)
741
                }
742
                .onDelete { offsets in
743
                    let measurements = charger.standbyPowerMeasurements
744
                    for index in offsets {
745
                        guard measurements.indices.contains(index) else { continue }
746
                        let measurement = measurements[index]
747
                        _ = appData.deleteChargerStandbyMeasurement(
748
                            id: measurement.id,
749
                            chargerID: charger.id
750
                        )
751
                    }
752
                }
753
            }
754
        }
Bogdan Timofte authored a month ago
755
        .environment(\.editMode, $editMode)
Bogdan Timofte authored a month ago
756
        .navigationTitle("Saved Measurements")
Bogdan Timofte authored a month ago
757
        .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
758
        .toolbar {
759
            ToolbarItem(placement: .primaryAction) {
Bogdan Timofte authored a month ago
760
                Button(editMode.isEditing ? "Done" : "Select") {
761
                    if editMode.isEditing {
762
                        editMode = .inactive
763
                        selectedMeasurementIDs.removeAll()
764
                    } else {
765
                        editMode = .active
766
                    }
767
                }
Bogdan Timofte authored a month ago
768
            }
769
        }
770

            
771
        if selectedMeasurementIDs.isEmpty {
772
            content
773
        } else {
774
            content.toolbar {
775
                ToolbarItem(placement: .destructiveAction) {
776
                    Button(role: .destructive) {
777
                        deleteMeasurements(
778
                            ids: selectedMeasurementIDs,
779
                            for: charger.id
780
                        )
781
                    } label: {
782
                        Image(systemName: "trash")
783
                    }
784
                }
785
            }
786
        }
787
    }
788

            
Bogdan Timofte authored a month ago
789
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
790
        if wattHours >= 1000 {
791
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
792
        }
793
        return "\(wattHours.format(decimalDigits: 2)) Wh"
794
    }
795

            
796
    private func formattedDuration(_ duration: TimeInterval) -> String {
797
        let formatter = DateComponentsFormatter()
798
        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
799
        formatter.unitsStyle = .abbreviated
800
        formatter.zeroFormattingBehavior = .pad
801
        return formatter.string(from: max(duration, 0)) ?? "0s"
802
    }
Bogdan Timofte authored a month ago
803

            
804
    private func deleteMeasurements(ids: Set<UUID>, for chargerID: UUID) {
805
        for id in ids {
806
            _ = appData.deleteChargerStandbyMeasurement(id: id, chargerID: chargerID)
807
        }
808
        selectedMeasurementIDs.removeAll()
Bogdan Timofte authored a month ago
809
        editMode = .inactive
Bogdan Timofte authored a month ago
810
    }
Bogdan Timofte authored a month ago
811
}
812

            
813
struct ChargerStandbyPowerMeasurementDetailView: View {
814
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
815
    @Environment(\.dismiss) private var dismiss
816

            
817
    @State private var deleteConfirmationVisibility = false
Bogdan Timofte authored a month ago
818

            
819
    let chargerID: UUID
820
    let measurementID: UUID
821

            
822
    var body: some View {
823
        Group {
824
            if let charger = appData.chargedDeviceSummary(id: chargerID),
825
               let measurement = charger.standbyPowerMeasurements.first(where: { $0.id == measurementID }) {
826
                ScrollView {
827
                    VStack(spacing: 18) {
828
                        MeterInfoCardView(title: charger.name, tint: .orange) {
829
                            MeterInfoRowView(label: "Saved", value: measurement.endedAt.format())
830
                            MeterInfoRowView(label: "Average", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
831
                            MeterInfoRowView(label: "Samples", value: "\(measurement.sampleCount)")
832
                            MeterInfoRowView(label: "Duration", value: formattedDuration(measurement.duration))
833
                        }
834

            
835
                        ChargerStandbyPowerMeasurementSnapshotView(measurement: measurement)
836
                    }
837
                    .padding()
838
                }
839
                .background(
840
                    LinearGradient(
841
                        colors: [.orange.opacity(0.16), Color.clear],
842
                        startPoint: .topLeading,
843
                        endPoint: .bottomTrailing
Bogdan Timofte authored a month ago
844
                )
845
                .ignoresSafeArea()
Bogdan Timofte authored a month ago
846
                )
847
                .navigationTitle("Measurement")
Bogdan Timofte authored a month ago
848
                .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
849
                .toolbar {
850
                    ToolbarItem(placement: .primaryAction) {
851
                        Button(role: .destructive) {
852
                            deleteConfirmationVisibility = true
853
                        } label: {
854
                            Label("Delete Measurement", systemImage: "trash")
855
                        }
856
                    }
857
                }
858
                .confirmationDialog(
859
                    "Delete this measurement?",
860
                    isPresented: $deleteConfirmationVisibility,
861
                    titleVisibility: .visible
862
                ) {
863
                    Button("Delete", role: .destructive) {
864
                        let didDelete = appData.deleteChargerStandbyMeasurement(
865
                            id: measurement.id,
866
                            chargerID: charger.id
867
                        )
868
                        if didDelete {
869
                            dismiss()
870
                        }
871
                    }
872
                    Button("Cancel", role: .cancel) {}
873
                } message: {
874
                    Text("This removes the saved standby measurement from the charger history and iCloud sync.")
875
                }
Bogdan Timofte authored a month ago
876
            } else {
877
                Text("This measurement is no longer available.")
878
                    .foregroundColor(.secondary)
879
                    .navigationTitle("Measurement")
Bogdan Timofte authored a month ago
880
                    .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
881
            }
882
        }
883
    }
884

            
885
    private func formattedDuration(_ duration: TimeInterval) -> String {
886
        let formatter = DateComponentsFormatter()
887
        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
888
        formatter.unitsStyle = .abbreviated
889
        formatter.zeroFormattingBehavior = .pad
890
        return formatter.string(from: max(duration, 0)) ?? "0s"
891
    }
892
}
893

            
894
private struct ChargerStandbyPowerMeasurementSnapshotView: View {
895
    let measurement: ChargerStandbyPowerMeasurementSummary
896

            
897
    var body: some View {
898
        VStack(spacing: 18) {
899
            stabilityCard
900
            projectionCard
901
            distributionCard
902
            statisticsCard
903
        }
904
    }
905

            
906
    private var stabilityCard: some View {
907
        VStack(alignment: .leading, spacing: 10) {
908
            HStack {
909
                VStack(alignment: .leading, spacing: 4) {
910
                    Text(measurement.isStable ? "Enough Samples" : "Still Settling")
911
                        .font(.headline)
912
                    Text("Saved \(measurement.endedAt.format())")
913
                        .font(.caption)
914
                        .foregroundColor(.secondary)
915
                }
916

            
917
                Spacer()
918

            
919
                Text(measurement.isStable ? "Ready" : "Live")
920
                    .font(.caption.weight(.semibold))
921
                    .padding(.horizontal, 10)
922
                    .padding(.vertical, 6)
923
                    .foregroundColor(measurement.isStable ? .green : .orange)
924
                    .meterCard(
925
                        tint: measurement.isStable ? .green : .orange,
926
                        fillOpacity: 0.10,
927
                        strokeOpacity: 0.16,
928
                        cornerRadius: 999
929
                    )
930
            }
931

            
932
            Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
933
                .font(.system(.largeTitle, design: .rounded).weight(.bold))
934
                .monospacedDigit()
935

            
936
            Text(
937
                "Recent drift: \((measurement.stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((measurement.stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(measurement.sampleCount) samples."
938
            )
939
            .font(.footnote)
940
            .foregroundColor(.secondary)
941
        }
942
        .frame(maxWidth: .infinity, alignment: .leading)
943
        .padding(18)
944
        .meterCard(
945
            tint: measurement.isStable ? .green : .orange,
946
            fillOpacity: 0.18,
947
            strokeOpacity: 0.24
948
        )
949
    }
950

            
951
    private var projectionCard: some View {
952
        MeterInfoCardView(
953
            title: "Consumption Projection",
954
            infoMessage: "These projections extrapolate only from the measured standby average. They do not say anything about charger behaviour under load.",
955
            tint: .teal
956
        ) {
957
            MeterInfoRowView(label: "Average Power", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
958
            MeterInfoRowView(label: "24 Hours", value: standbyEnergyLabel(measurement.projectedDailyEnergyWh))
959
            MeterInfoRowView(label: "7 Days", value: standbyEnergyLabel(measurement.projectedWeeklyEnergyWh))
960
            MeterInfoRowView(label: "30 Days", value: standbyEnergyLabel(measurement.projectedMonthlyEnergyWh))
961
            MeterInfoRowView(label: "1 Year", value: standbyEnergyLabel(measurement.projectedYearlyEnergyWh))
962
        }
963
    }
964

            
965
    private var distributionCard: some View {
Bogdan Timofte authored a month ago
966
        StandbyPowerDistributionCard(
967
            histogram: measurement.storedHistogram,
968
            averagePowerWatts: measurement.averagePowerWatts,
969
            standardDeviationPowerWatts: measurement.standardDeviationPowerWatts,
970
            tint: .orange,
971
            showExport: true
972
        )
Bogdan Timofte authored a month ago
973
    }
974

            
975
    private var statisticsCard: some View {
976
        MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
977
            MeterInfoRowView(label: "Median", value: "\(measurement.medianPowerWatts.format(decimalDigits: 3)) W")
978
            MeterInfoRowView(label: "Minimum", value: "\(measurement.minimumPowerWatts.format(decimalDigits: 3)) W")
979
            MeterInfoRowView(label: "Maximum", value: "\(measurement.maximumPowerWatts.format(decimalDigits: 3)) W")
980
            MeterInfoRowView(label: "Spread σ", value: "\(measurement.standardDeviationPowerWatts.format(decimalDigits: 4)) W")
981
            MeterInfoRowView(label: "Variation", value: "\(Int((measurement.coefficientOfVariation * 100).rounded()))%")
982
            MeterInfoRowView(label: "Mean Current", value: "\(measurement.averageCurrentAmps.format(decimalDigits: 3)) A")
983
            MeterInfoRowView(label: "Mean Voltage", value: "\(measurement.averageVoltageVolts.format(decimalDigits: 3)) V")
984
            MeterInfoRowView(label: "Power Density", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W steady")
985
        }
986
    }
987

            
988
    private func standbyEnergyLabel(_ wattHours: Double) -> String {
989
        if wattHours >= 1000 {
990
            return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
991
        }
992
        return "\(wattHours.format(decimalDigits: 2)) Wh"
993
    }
994
}