USB-Meter / USB Meter / Model / ChargingWindowDetector.swift
Newer Older
94 lines | 3.498kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargingWindowDetector.swift
3
//  USB Meter
4
//
5

            
6
import Foundation
7

            
8
enum ChargingWindowDetector {
9

            
10
    struct DetectedWindow {
11
        let start: Date
12
        let end: Date
13
        // How much shorter the window is vs total session (0..1). Higher = more trimming needed.
14
        let trimRatio: Double
15
    }
16

            
17
    // Power above this threshold counts as "charging activity" (Watts)
18
    private static let activityThreshold = 0.05
19
    // A charging segment must last at least this long to be considered real
20
    private static let minimumSegmentDuration: TimeInterval = 3 * 60
21
    // Gaps shorter than this between active segments are bridged (e.g. brief wireless drop)
22
    private static let mergeGapDuration: TimeInterval = 120
23
    // Padding added before first and after last sample of the detected window
24
    private static let padding: TimeInterval = 30
25
    // Only surface detection when active window is shorter than this fraction of total session
26
    // e.g. 0.30 means: show banner if active charging < 70% of total session time
27
    static let significantTrimThreshold = 0.30
28

            
29
    static func detect(
30
        samples: [ChargeSessionSampleSummary],
31
        sessionStart: Date,
32
        sessionEnd: Date
33
    ) -> DetectedWindow? {
34
        guard !samples.isEmpty else { return nil }
35
        let sorted = samples.sorted { $0.timestamp < $1.timestamp }
36

            
37
        // Build contiguous active segments
38
        struct Segment { var start: Date; var end: Date }
39
        var segments: [Segment] = []
40
        var segStart: Date?
41
        var segEnd: Date?
42

            
43
        for sample in sorted {
44
            if sample.averagePowerWatts >= activityThreshold {
45
                if segStart == nil { segStart = sample.timestamp }
46
                segEnd = sample.timestamp
47
            } else {
48
                if let s = segStart, let e = segEnd {
49
                    segments.append(Segment(start: s, end: e))
50
                }
51
                segStart = nil
52
                segEnd = nil
53
            }
54
        }
55
        if let s = segStart, let e = segEnd {
56
            segments.append(Segment(start: s, end: e))
57
        }
58

            
59
        guard !segments.isEmpty else { return nil }
60

            
61
        // Merge segments separated by short gaps
62
        var merged: [Segment] = [segments[0]]
63
        for seg in segments.dropFirst() {
64
            let gap = seg.start.timeIntervalSince(merged[merged.count - 1].end)
65
            if gap <= mergeGapDuration {
66
                merged[merged.count - 1].end = seg.end
67
            } else {
68
                merged.append(seg)
69
            }
70
        }
71

            
72
        // Filter out short segments
73
        let significant = merged.filter {
74
            $0.end.timeIntervalSince($0.start) >= minimumSegmentDuration
75
        }
76

            
77
        guard !significant.isEmpty else { return nil }
78

            
79
        // Pick primary segment: the longest one
80
        let primary = significant.max { a, b in
81
            a.end.timeIntervalSince(a.start) < b.end.timeIntervalSince(b.start)
82
        }!
83

            
84
        let windowStart = max(sessionStart, primary.start.addingTimeInterval(-padding))
85
        let windowEnd   = min(sessionEnd,   primary.end.addingTimeInterval(padding))
86

            
87
        let sessionDuration = sessionEnd.timeIntervalSince(sessionStart)
88
        let windowDuration  = windowEnd.timeIntervalSince(windowStart)
89
        guard sessionDuration > 0 else { return nil }
90

            
91
        let trimRatio = 1.0 - (windowDuration / sessionDuration)
92
        return DetectedWindow(start: windowStart, end: windowEnd, trimRatio: trimRatio)
93
    }
94
}