Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions Sources/LoopAlgorithm/SampleValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,40 @@ public extension Sequence where Element: TimelineValue {
return filterDateRange(interval.start, interval.end)
}
}

/// Fast binary-search filter for ordered timeline arrays. Picks up when the
/// collection conforms to RandomAccessCollection with Int index (i.e. Array)
/// and the elements are sorted by startDate (which is the contract for all
/// schedule arrays — sensitivity / basal / carb-ratio / target — across this
/// codebase). Reduces filterDateRange from O(N) to O(log N) per call.
///
/// LoopEval sims with per-step ISF schedules (`--candidate-isf-csv`) call
/// filterDateRange ~1.5M times on a 60-day window; this dropped sim time
/// from ~30 min to ~2 min on that workload.
public extension RandomAccessCollection where Element: TimelineValue, Index == Int {
func filterDateRange(_ startDate: Date?, _ endDate: Date?) -> [Element] {
guard !isEmpty else { return [] }
// Lower bound: first index where element.endDate >= startDate
var lo = startIndex
if let startDate {
var l = startIndex, r = endIndex
while l < r {
let m = (l + r) / 2
if self[m].endDate < startDate { l = m + 1 } else { r = m }
}
lo = l
}
// Upper bound: first index where element.startDate > endDate
var hi = endIndex
if let endDate {
var l = lo, r = endIndex
while l < r {
let m = (l + r) / 2
if self[m].startDate <= endDate { l = m + 1 } else { r = m }
}
hi = l
}
guard lo < hi else { return [] }
return Array(self[lo..<hi])
}
}
138 changes: 138 additions & 0 deletions Tests/LoopAlgorithmTests/FilterDateRangeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//
// FilterDateRangeTests.swift
// LoopAlgorithm
//
// Tests for the binary-search filterDateRange overload on
// RandomAccessCollection<TimelineValue> — must produce identical output
// to the Sequence-based linear-filter version.
//

import XCTest
@testable import LoopAlgorithm

final class FilterDateRangeTests: XCTestCase {

/// Minimal TimelineValue with a date range.
private struct Sample: TimelineValue, Equatable, CustomStringConvertible {
let startDate: Date
let endDate: Date
let id: Int
var description: String { "Sample(id=\(id), start=\(startDate.timeIntervalSinceReferenceDate.rounded()), end=\(endDate.timeIntervalSinceReferenceDate.rounded()))" }
}

/// Linear-scan reference implementation (the Sequence-based version that
/// the binary-search overload must match).
private func linearFilter(_ items: [Sample], _ start: Date?, _ end: Date?) -> [Sample] {
return items.filter { value in
if let start, value.endDate < start { return false }
if let end, value.startDate > end { return false }
return true
}
}

private func contiguousSamples(count: Int, segmentSeconds: TimeInterval = 300,
startingAt: Date = Date(timeIntervalSince1970: 1700000000)) -> [Sample] {
return (0..<count).map { i in
Sample(
startDate: startingAt.addingTimeInterval(TimeInterval(i) * segmentSeconds),
endDate: startingAt.addingTimeInterval(TimeInterval(i + 1) * segmentSeconds),
id: i
)
}
}

// MARK: - Equivalence

func testEmptyCollection() {
let empty: [Sample] = []
XCTAssertEqual(empty.filterDateRange(Date(), Date().addingTimeInterval(60)), [])
}

func testBothBoundsNil() {
let samples = contiguousSamples(count: 20)
XCTAssertEqual(samples.filterDateRange(nil, nil), samples)
}

func testOnlyStartDate() {
let samples = contiguousSamples(count: 20)
let start = samples[5].startDate
XCTAssertEqual(samples.filterDateRange(start, nil),
linearFilter(samples, start, nil))
}

func testOnlyEndDate() {
let samples = contiguousSamples(count: 20)
let end = samples[15].endDate
XCTAssertEqual(samples.filterDateRange(nil, end),
linearFilter(samples, nil, end))
}

func testBothBoundsInMiddle() {
let samples = contiguousSamples(count: 20)
let start = samples[5].startDate
let end = samples[15].endDate
XCTAssertEqual(samples.filterDateRange(start, end),
linearFilter(samples, start, end))
}

func testStartBeforeAll() {
let samples = contiguousSamples(count: 20)
let start = samples[0].startDate.addingTimeInterval(-3600)
XCTAssertEqual(samples.filterDateRange(start, nil),
linearFilter(samples, start, nil))
}

func testEndAfterAll() {
let samples = contiguousSamples(count: 20)
let end = samples.last!.endDate.addingTimeInterval(3600)
XCTAssertEqual(samples.filterDateRange(nil, end),
linearFilter(samples, nil, end))
}

func testRangeFullyOutsideAllSamples() {
let samples = contiguousSamples(count: 20)
let start = samples.last!.endDate.addingTimeInterval(60)
let end = samples.last!.endDate.addingTimeInterval(3600)
XCTAssertEqual(samples.filterDateRange(start, end), [])
}

func testRangeBeforeAllSamples() {
let samples = contiguousSamples(count: 20)
let start = samples[0].startDate.addingTimeInterval(-3600)
let end = samples[0].startDate.addingTimeInterval(-60)
XCTAssertEqual(samples.filterDateRange(start, end), [])
}

func testRangeExactlyMatchesOneSegment() {
let samples = contiguousSamples(count: 20)
let s = samples[7]
XCTAssertEqual(samples.filterDateRange(s.startDate, s.endDate),
linearFilter(samples, s.startDate, s.endDate))
}

func testRandomizedFuzz() {
// 100 random queries on a 200-element schedule.
let samples = contiguousSamples(count: 200)
let baseT = samples[0].startDate.timeIntervalSinceReferenceDate
let totalSpan = samples.last!.endDate.timeIntervalSince(samples[0].startDate)
var rng = SystemRandomNumberGenerator()
for _ in 0..<100 {
let startOffset = Double.random(in: -1000...(totalSpan + 1000), using: &rng)
let endOffset = startOffset + Double.random(in: 0...(totalSpan + 1000), using: &rng)
let start = Date(timeIntervalSinceReferenceDate: baseT + startOffset)
let end = Date(timeIntervalSinceReferenceDate: baseT + endOffset)
XCTAssertEqual(samples.filterDateRange(start, end),
linearFilter(samples, start, end),
"binary-search and linear filter must agree for [\(start), \(end)]")
}
}

func testSingleSampleCollection() {
let samples = contiguousSamples(count: 1)
let s = samples[0]
XCTAssertEqual(samples.filterDateRange(s.startDate, s.endDate), samples)
XCTAssertEqual(samples.filterDateRange(nil, nil), samples)
XCTAssertEqual(samples.filterDateRange(s.endDate.addingTimeInterval(60), nil), [])
XCTAssertEqual(samples.filterDateRange(nil, s.startDate.addingTimeInterval(-60)), [])
}
}