1 contributor
//
// 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)
}
}