USB-Meter / USB Meter / Views / Meter / Tabs / Live / ChargerStandbyPowerWizardView.swift
Newer Older
991 lines | 40.99kb
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)
56
        .sheet(isPresented: $chargerLibraryVisibility) {
57
            ChargedDeviceLibrarySheetView(
58
                meterMACAddress: selectedMeterSummary?.macAddress ?? "",
59
                meterTint: selectedMeter?.color ?? .orange,
60
                mode: .charger
61
            )
62
            .environmentObject(appData)
63
        }
64
        .confirmationDialog(
65
            "Discard the current standby measurement?",
66
            isPresented: $discardConfirmationVisibility,
67
            titleVisibility: .visible
68
        ) {
69
            Button("Discard", role: .destructive) {
70
                if let activeSession {
71
                    _ = appData.finishChargerStandbyMeasurement(for: activeSession.meterMACAddress, save: false)
72
                }
73
            }
74
            Button("Cancel", role: .cancel) {}
75
        } message: {
76
            Text("The current sample set will be removed and nothing will be saved for this charger.")
77
        }
78
    }
79

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

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

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

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

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

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

            
112
        return nil
113
    }
114

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
193
        return "Use Meter"
194
    }
195

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

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

            
233
                    HStack(spacing: 8) {
234
                        Text("Use Meter")
235
                            .font(.subheadline.weight(.semibold))
236
                        ContextInfoButton(
237
                            title: "Use Meter",
238
                            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."
239
                        )
240
                    }
241

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

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

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

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

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

            
296
    private func activeMeasurementCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
297
        MeterInfoCardView(
298
            title: "Measurement Running",
299
            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.",
300
            tint: .orange
301
        ) {
302
            MeterInfoRowView(label: "Meter", value: selectedMeterSummary?.displayName ?? session.meterMACAddress)
303
            MeterInfoRowView(label: "Charger", value: selectedCharger?.name ?? "Selected charger")
304
            MeterInfoRowView(label: "Status", value: session.readinessDescription)
305
            MeterInfoRowView(label: "Samples", value: "\(session.sampleCount)")
306

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

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

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

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

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

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

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

            
386
                Spacer()
387

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

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

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

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

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

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

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

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

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

            
481
}
482

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
692
    let chargerID: UUID
693

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

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

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

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

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

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

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

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

            
814
    @State private var deleteConfirmationVisibility = false
Bogdan Timofte authored a month ago
815

            
816
    let chargerID: UUID
817
    let measurementID: UUID
818

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

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

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

            
891
private struct ChargerStandbyPowerMeasurementSnapshotView: View {
892
    let measurement: ChargerStandbyPowerMeasurementSummary
893

            
894
    var body: some View {
895
        VStack(spacing: 18) {
896
            stabilityCard
897
            projectionCard
898
            distributionCard
899
            statisticsCard
900
        }
901
    }
902

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

            
914
                Spacer()
915

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

            
929
            Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
930
                .font(.system(.largeTitle, design: .rounded).weight(.bold))
931
                .monospacedDigit()
932

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

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

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

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

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