Skip to content

Commit cfcc34a

Browse files
fix(transform): split uncertain flood gaps at midpoint (#146)
1 parent b706a50 commit cfcc34a

2 files changed

Lines changed: 39 additions & 40 deletions

File tree

aw_transform/flood.py

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,20 @@
99

1010

1111
def flood(events: List[Event], pulsetime: float = 5) -> List[Event]:
12-
"""
13-
Takes a list of events and "floods" any empty space between events by extending one of the surrounding events to cover the empty space.
12+
"""Fill short gaps between events and merge nearby equal-data events.
13+
14+
Events are ordered by timestamp and duration. Gaps no larger than
15+
``pulsetime`` are filled. Equal-data neighbours merge across the gap; when
16+
their data differs, both events extend to the midpoint. Splitting an
17+
uncertain interval evenly is independent of the surrounding event lengths
18+
and matches the Rust implementation.
1419
15-
For more details on flooding, see this issue:
16-
- https://github.com/ActivityWatch/activitywatch/issues/124
20+
Overlapping equal-data events merge. For overlapping differing-data events,
21+
final normalization gives the later event precedence so the result never
22+
double-counts time.
23+
24+
See https://github.com/ActivityWatch/activitywatch/issues/124 for the data
25+
collection uncertainty that flooding is intended to handle.
1726
"""
1827
# Originally written in aw-research: https://github.com/ActivityWatch/aw-analysis/blob/7da1f2cd8552f866f643501de633d74cdecab168/aw_analysis/flood.py
1928
# NOTE: This algorithm has a lot of smaller details that need to be
@@ -61,27 +70,24 @@ def flood(events: List[Event], pulsetime: float = 5) -> List[Event]:
6170
elif -negative_gap_trim_thres < gap <= timedelta(seconds=pulsetime):
6271
e2_end = e2.timestamp + e2.duration
6372

64-
# Prioritize flooding from the longer event
65-
if e1.duration >= e2.duration:
66-
if e1.data == e2.data:
67-
# Extend e1 to the end of e2
68-
# Set duration of e2 to zero (mark to delete)
73+
if e1.data == e2.data:
74+
# Preserve the longer neighbour's extent while merging across
75+
# the gap, matching the existing semantics for equal data.
76+
if e1.duration >= e2.duration:
6977
e1.duration = e2_end - e1.timestamp
7078
e2.timestamp = e2_end
7179
e2.duration = timedelta(0)
7280
else:
73-
# Extend e1 to the start of e2
74-
e1.duration = e2.timestamp - e1.timestamp
75-
else:
76-
if e1.data == e2.data:
77-
# Extend e2 to the start of e1, discard e1
7881
e2.timestamp = e1.timestamp
7982
e2.duration = e2_end - e2.timestamp
8083
e1.duration = timedelta(0)
81-
else:
82-
# Extend e2 backwards to end of e1
83-
e2.timestamp = e1.timestamp + e1.duration
84-
e2.duration = e2_end - e2.timestamp
84+
else:
85+
# The gap is an interval of uncertainty: without evidence that
86+
# either neighbour owns more of it, split it at the midpoint.
87+
midpoint = e1.timestamp + e1.duration + gap / 2
88+
e1.duration = midpoint - e1.timestamp
89+
e2.timestamp = midpoint
90+
e2.duration = e2_end - midpoint
8591

8692
# Pairwise flooding can mutate an event after its previous pair has already
8793
# been processed. Normalize the final stream so downstream consumers never

tests/test_flood.py

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@
88
td1s = timedelta(seconds=1)
99

1010

11-
def test_flood_forward():
11+
def test_flood_differing_data_meet_at_gap_midpoint():
1212
events = [
1313
Event(timestamp=now, duration=10, data={"a": 0}),
14-
Event(timestamp=now + 15 * td1s, duration=5, data={"b": 1}),
14+
Event(timestamp=now + 14 * td1s, duration=5, data={"b": 1}),
1515
]
16+
1617
flooded = flood(events)
17-
assert (flooded[0].timestamp + flooded[0].duration) - flooded[
18-
1
19-
].timestamp == timedelta(0)
18+
19+
assert flooded == [
20+
Event(timestamp=now, duration=12, data={"a": 0}),
21+
Event(timestamp=now + 12 * td1s, duration=7, data={"b": 1}),
22+
]
2023

2124

2225
def test_flood_forward_merge():
@@ -29,17 +32,6 @@ def test_flood_forward_merge():
2932
assert flooded[0].duration == timedelta(seconds=20)
3033

3134

32-
def test_flood_backward():
33-
events = [
34-
Event(timestamp=now, duration=5, data={"a": 0}),
35-
Event(timestamp=now + 10 * td1s, duration=10, data={"b": 1}),
36-
]
37-
flooded = flood(events)
38-
assert (flooded[0].timestamp + flooded[0].duration) - flooded[
39-
1
40-
].timestamp == timedelta(0)
41-
42-
4335
def test_flood_backward_merge():
4436
events = [
4537
Event(timestamp=now, duration=5),
@@ -97,8 +89,8 @@ def test_flood_normalization_preserves_non_overlapping_tail():
9789

9890
assert flooded == [
9991
Event(timestamp=now, duration=5, data={"title": "first"}),
100-
Event(timestamp=now + 5 * td1s, duration=7, data={"title": "second"}),
101-
events[2],
92+
Event(timestamp=now + 5 * td1s, duration=4.5, data={"title": "second"}),
93+
Event(timestamp=now + 9.5 * td1s, duration=3.5, data={"title": "third"}),
10294
]
10395

10496

@@ -137,11 +129,12 @@ def test_flood_with_custom_pulsetime():
137129
total_duration_default = sum((e.duration for e in flooded_default), timedelta(0))
138130
assert total_duration_default == timedelta(seconds=10)
139131

140-
# pulsetime=31: gap (30s) <= pulsetime, so event 1 extends to meet event 2
132+
# pulsetime=31: gap (30s) <= pulsetime, so both events extend to midpoint
141133
flooded_custom = flood(events, pulsetime=31)
142-
assert len(flooded_custom) == 2
143-
total_duration_custom = sum((e.duration for e in flooded_custom), timedelta(0))
144-
assert total_duration_custom == timedelta(seconds=40)
134+
assert flooded_custom == [
135+
Event(timestamp=now, duration=20, data={"a": 0}),
136+
Event(timestamp=now + 20 * td1s, duration=20, data={"b": 1}),
137+
]
145138

146139

147140
def test_flood_idempotent():

0 commit comments

Comments
 (0)