@@ -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
286335class TestDelaySummaryGeneration :
0 commit comments