Skip to content

Commit 85b7846

Browse files
committed
Updated bunching detection
1 parent 1cf7267 commit 85b7846

File tree

4 files changed

+93
-37
lines changed

4 files changed

+93
-37
lines changed

lib/detection.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,18 +212,20 @@ def find_clusters(sorted_trains):
212212
return clusters
213213

214214
# Find clusters on each track
215+
# For upper track (westbound): trains queue to the RIGHT of stations they're entering
216+
# Find the nearest station to the front (left edge) of the cluster
215217
for cluster in find_clusters(upper_trains):
216218
cluster_left_x = min(t['x'] for t in cluster)
217219
nearest_station = None
218220
min_distance = float('inf')
219221
for station_code, station_x in STATION_X_POSITIONS.items():
220222
if station_code in EXCLUDED_UPPER:
221223
continue
222-
if station_x <= cluster_left_x:
223-
distance = cluster_left_x - station_x
224-
if distance < min_distance:
225-
min_distance = distance
226-
nearest_station = station_code
224+
# Find nearest station to cluster front (absolute distance)
225+
distance = abs(station_x - cluster_left_x)
226+
if distance < min_distance:
227+
min_distance = distance
228+
nearest_station = station_code
227229
if nearest_station and min_distance < 300:
228230
bunching_incidents.append({
229231
'station': nearest_station,
@@ -232,13 +234,16 @@ def find_clusters(sorted_trains):
232234
'train_count': len(cluster),
233235
})
234236

237+
# For lower track (eastbound): trains queue to the LEFT of stations they're entering
238+
# Find the nearest station at or to the RIGHT of the cluster front
235239
for cluster in find_clusters(lower_trains):
236240
cluster_right_x = max(t['x'] for t in cluster)
237241
nearest_station = None
238242
min_distance = float('inf')
239243
for station_code, station_x in STATION_X_POSITIONS.items():
240244
if station_code in EXCLUDED_LOWER:
241245
continue
246+
# Station must be at or to the right of the cluster front
242247
if station_x >= cluster_right_x:
243248
distance = station_x - cluster_right_x
244249
if distance < min_distance:

scripts/detection_viewer.py

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,14 @@ def detect_train_bunching(trains, threshold=4, cluster_distance=70):
178178
[{'station': 'PO', 'track': 'upper', 'direction': 'Westbound', 'train_count': 5}, ...]
179179
Direction is 'Northbound'/'Southbound' for CT/US/YB, 'Westbound'/'Eastbound' for others.
180180
"""
181-
# Stations to exclude from bunching analysis:
182-
# - CT (Chinatown) and EM (Embarcadero): turnaround points where trains queue normally
183-
# - Internal stations (MN, FP, TT): not passenger stations
184-
EXCLUDED_STATIONS = {'CT', 'EM', 'MN', 'FP', 'TT'}
181+
# Internal stations to always exclude
182+
INTERNAL_STATIONS = {'MN', 'FP', 'TT'}
183+
184+
# Track-specific exclusions for turnaround points where bunching is normal:
185+
# - Upper track (Westbound/Northbound): CT is the northern terminus
186+
# - Lower track (Eastbound/Southbound): EM is the eastern terminus, MO is adjacent
187+
EXCLUDED_UPPER = INTERNAL_STATIONS | {'CT'}
188+
EXCLUDED_LOWER = INTERNAL_STATIONS | {'EM', 'MO'}
185189

186190
# Direction terminology: CT, US, YB are Northbound/Southbound; others are Westbound/Eastbound
187191
NORTH_SOUTH_STATIONS = {'CT', 'US', 'YB'}
@@ -227,50 +231,48 @@ def find_clusters(sorted_trains):
227231
upper_clusters = find_clusters(upper_trains)
228232
lower_clusters = find_clusters(lower_trains)
229233

230-
# For each cluster, find the station it's approaching
234+
# For upper track (westbound): trains queue to the RIGHT of stations they're entering
235+
# Find the nearest station to the front (left edge) of the cluster
231236
for cluster in upper_clusters:
232-
# Upper track moves westbound (left), so cluster approaches the station to its left
233237
cluster_left_x = min(t['x'] for t in cluster)
234238

235-
# Find the nearest station to the left of (or at) this cluster
236239
nearest_station = None
237240
min_distance = float('inf')
238241
for station_code, station_x in STATION_X_POSITIONS.items():
239-
if station_code in EXCLUDED_STATIONS:
242+
if station_code in EXCLUDED_UPPER:
240243
continue
241-
# Station must be to the left of or at the cluster
242-
if station_x <= cluster_left_x:
243-
distance = cluster_left_x - station_x
244-
if distance < min_distance:
245-
min_distance = distance
246-
nearest_station = station_code
244+
# Find nearest station to cluster front (absolute distance)
245+
distance = abs(station_x - cluster_left_x)
246+
if distance < min_distance:
247+
min_distance = distance
248+
nearest_station = station_code
247249

248-
if nearest_station and min_distance < 300: # Must be reasonably close to a station
250+
if nearest_station and min_distance < 300:
249251
bunching_incidents.append({
250252
'station': nearest_station,
251253
'track': 'upper',
252254
'direction': get_direction(nearest_station, 'upper'),
253255
'train_count': len(cluster),
254256
})
255257

258+
# For lower track (eastbound): trains queue to the LEFT of stations they're entering
259+
# Find the nearest station at or to the RIGHT of the cluster front
256260
for cluster in lower_clusters:
257-
# Lower track moves eastbound (right), so cluster approaches the station to its right
258261
cluster_right_x = max(t['x'] for t in cluster)
259262

260-
# Find the nearest station to the right of (or at) this cluster
261263
nearest_station = None
262264
min_distance = float('inf')
263265
for station_code, station_x in STATION_X_POSITIONS.items():
264-
if station_code in EXCLUDED_STATIONS:
266+
if station_code in EXCLUDED_LOWER:
265267
continue
266-
# Station must be to the right of or at the cluster
268+
# Station must be at or to the right of the cluster front
267269
if station_x >= cluster_right_x:
268270
distance = station_x - cluster_right_x
269271
if distance < min_distance:
270272
min_distance = distance
271273
nearest_station = station_code
272274

273-
if nearest_station and min_distance < 300: # Must be reasonably close to a station
275+
if nearest_station and min_distance < 300:
274276
bunching_incidents.append({
275277
'station': nearest_station,
276278
'track': 'lower',
120 KB
Loading

tests/test_system_status.py

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -215,23 +215,38 @@ def test_bunching_detected_lower_track(self):
215215
assert any(b['station'] == 'PO' for b in bunching), f"Should detect bunching approaching PO, got: {bunching}"
216216

217217
def test_excluded_stations_ignored(self):
218-
"""Chinatown (CT) and Embarcadero (EM) should not report bunching."""
218+
"""Track-specific excluded stations should not report bunching.
219+
220+
Exclusions by track:
221+
- Upper track (westbound): CT (Chinatown) is the northern terminus
222+
- Lower track (eastbound): EM (Embarcadero), MO (Montgomery) are turnaround area
223+
"""
219224
from lib.detection import detect_train_bunching
220225

221-
# CT is at x=1564, EM is at x=1182
222-
# Bunch trains right at CT (upper track, trains to right of CT)
223-
# These are past CT heading towards end of line - should be excluded
224-
trains = [
226+
# CT is at x=1564 - excluded for upper track (terminus)
227+
# Bunch trains right at CT (upper track)
228+
upper_trains = [
225229
{'id': 'W2010LL', 'x': 1580, 'track': 'upper'},
226230
{'id': 'M2089MM', 'x': 1630, 'track': 'upper'}, # 50px gap
227231
{'id': 'D2099J', 'x': 1680, 'track': 'upper'}, # 50px gap
228232
{'id': 'F2164SS', 'x': 1730, 'track': 'upper'}, # 50px gap
229233
]
230234

231-
bunching = detect_train_bunching(trains)
232-
# Should not detect bunching at CT or EM (excluded turnaround stations)
233-
assert not any(b['station'] == 'CT' for b in bunching), f"CT should be excluded, got: {bunching}"
234-
assert not any(b['station'] == 'EM' for b in bunching), f"EM should be excluded, got: {bunching}"
235+
bunching = detect_train_bunching(upper_trains)
236+
assert not any(b['station'] == 'CT' for b in bunching), f"CT should be excluded for upper track, got: {bunching}"
237+
238+
# EM is at x=1182 - excluded for lower track (turnaround)
239+
# Bunch trains right at EM (lower track, approaching from west)
240+
lower_trains = [
241+
{'id': 'W2010LL', 'x': 1100, 'track': 'lower'},
242+
{'id': 'M2089MM', 'x': 1150, 'track': 'lower'}, # 50px gap
243+
{'id': 'D2099J', 'x': 1200, 'track': 'lower'}, # 50px gap - at EM
244+
{'id': 'F2164SS', 'x': 1250, 'track': 'lower'}, # 50px gap - past EM
245+
]
246+
247+
bunching = detect_train_bunching(lower_trains)
248+
assert not any(b['station'] == 'EM' for b in bunching), f"EM should be excluded for lower track, got: {bunching}"
249+
assert not any(b['station'] == 'MO' for b in bunching), f"MO should be excluded for lower track, got: {bunching}"
235250

236251
def test_three_trains_not_bunching(self):
237252
"""3 trains clustered should NOT trigger bunching (threshold is 4)."""
@@ -278,9 +293,43 @@ def test_bunching_triggers_yellow_status(self):
278293
status = calculate_system_status(trains_with_routes, [], [], bunching)
279294
assert status == 'yellow', f"Expected yellow with bunching, got {status}"
280295

281-
# Without bunching, same trains should be green
282-
status_no_bunching = calculate_system_status(trains_with_routes, [], [], [])
283-
assert status_no_bunching == 'green', f"Expected green without bunching, got {status_no_bunching}"
296+
def test_bunching_at_embarcadero_upper_track(self):
297+
"""Upper track bunching near Embarcadero should report EM, not Montgomery.
298+
299+
EM is at x=1182. Trains clustered around x=1167-1325 are at/near Embarcadero,
300+
not approaching Montgomery (x=1054). Uses absolute distance for upper track.
301+
"""
302+
from lib.detection import detect_train_bunching
303+
304+
# Real-world scenario: 5 trains clustered near Embarcadero on upper track
305+
# EM is at x=1182, MO is at x=1054
306+
trains = [
307+
{'id': 'W2174KK', 'x': 1167, 'track': 'upper'},
308+
{'id': '5109ML', 'x': 1199, 'track': 'upper'}, # 32px gap
309+
{'id': 'W2148MM', 'x': 1254, 'track': 'upper'}, # 55px gap
310+
{'id': 'D2054J', 'x': 1273, 'track': 'upper'}, # 19px gap
311+
{'id': 'W2004KK', 'x': 1325, 'track': 'upper'}, # 52px gap
312+
]
313+
314+
bunching = detect_train_bunching(trains)
315+
assert len(bunching) == 1, f"Should detect one bunching incident, got: {bunching}"
316+
assert bunching[0]['station'] == 'EM', f"Should report bunching at EM (nearest to cluster), got: {bunching[0]['station']}"
317+
assert bunching[0]['train_count'] == 5
318+
assert bunching[0]['direction'] == 'Westbound'
319+
320+
def test_bunching_real_image(self):
321+
"""Test bunching detection on real image with visible bunching at Embarcadero."""
322+
result = detect_system_status(str(IMAGES_DIR / "muni_snapshot_20260203_165648.jpg"))
323+
324+
# This image shows bunching on upper track near Embarcadero
325+
bunching = result.get('delays_bunching', [])
326+
assert len(bunching) > 0, f"Should detect bunching in this image, got none"
327+
328+
# Should report bunching at Embarcadero (not Montgomery)
329+
em_bunching = [b for b in bunching if b['station'] == 'EM']
330+
assert len(em_bunching) > 0, f"Should detect bunching at EM, got: {bunching}"
331+
assert em_bunching[0]['track'] == 'upper'
332+
assert em_bunching[0]['direction'] == 'Westbound'
284333

285334

286335
class TestDelaySummaryGeneration:

0 commit comments

Comments
 (0)