USB-Meter / USB Meter / Model / ChargingWindowDetector.swift
1 contributor
94 lines | 3.498kb
//
//  ChargingWindowDetector.swift
//  USB Meter
//

import Foundation

enum ChargingWindowDetector {

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

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

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

        // Build contiguous active segments
        struct Segment { var start: Date; var end: Date }
        var segments: [Segment] = []
        var segStart: Date?
        var segEnd: Date?

        for sample in sorted {
            if sample.averagePowerWatts >= activityThreshold {
                if segStart == nil { segStart = sample.timestamp }
                segEnd = sample.timestamp
            } else {
                if let s = segStart, let e = segEnd {
                    segments.append(Segment(start: s, end: e))
                }
                segStart = nil
                segEnd = nil
            }
        }
        if let s = segStart, let e = segEnd {
            segments.append(Segment(start: s, end: e))
        }

        guard !segments.isEmpty else { return nil }

        // Merge segments separated by short gaps
        var merged: [Segment] = [segments[0]]
        for seg in segments.dropFirst() {
            let gap = seg.start.timeIntervalSince(merged[merged.count - 1].end)
            if gap <= mergeGapDuration {
                merged[merged.count - 1].end = seg.end
            } else {
                merged.append(seg)
            }
        }

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

        guard !significant.isEmpty else { return nil }

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

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

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

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