Skip to content

Commit 1b80e6c

Browse files
Merge pull request #479 from JRroony/fix/issue-409-stop-location-type-merge
Fix stop merging across incompatible location types
2 parents 3b1b2a2 + 5aaa617 commit 1b80e6c

3 files changed

Lines changed: 343 additions & 0 deletions

File tree

onebusaway-gtfs-merge/src/main/java/org/onebusaway/gtfs_merge/strategies/StopMergeStrategy.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ public StopMergeStrategy() {
2828
_duplicateScoringStrategy.addStrategy(new StopDistanceDuplicateScoringStrategy());
2929
}
3030

31+
/**
32+
* A stop_time may only reference a stop with location_type=0, never a station or other location
33+
* type. If two feeds share a stop_id but disagree on location_type, they are not the same GTFS
34+
* entity and must not be merged into one.
35+
*/
36+
@Override
37+
protected boolean rejectDuplicateOverDifferences(
38+
GtfsMergeContext context, Stop sourceEntity, Stop targetDuplicate) {
39+
return sourceEntity.getLocationType() != targetDuplicate.getLocationType();
40+
}
41+
3142
@Override
3243
protected void replaceDuplicateEntry(GtfsMergeContext context, Stop oldStop, Stop newStop) {
3344
GtfsRelationalDao source = context.getSource();

onebusaway-gtfs-merge/src/test/java/org/onebusaway/gtfs_merge/GtfsMergerTest.java

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,163 @@ public void testStopTimeProxies() throws IOException {
471471
}
472472
}
473473

474+
/**
475+
* Reproduction for issue #409: merging two feeds that share a stop_id but disagree on
476+
* location_type (one is location_type=0 "stop", the other location_type=1 "station") should never
477+
* leave a StopTime referencing the location_type=1 entity, since GTFS trips may only visit
478+
* individual stops, not stations.
479+
*
480+
* <p>Input order: station feed listed first (_oldGtfs), platform feed listed second (_newGtfs).
481+
* GtfsMerger processes the LAST listed feed first (see GtfsMerger#run), so the platform feed's
482+
* stop (location_type=0) is registered as the target stop first. When the station feed's stop
483+
* (location_type=1) is processed, StopMergeStrategy#rejectDuplicateOverDifferences rejects it as
484+
* a duplicate due to the location_type mismatch, so it is kept as a separate, renamed Stop
485+
* instead of being merged.
486+
*/
487+
@Test
488+
public void testLocationTypeMismatch_StationFeedFirstPlatformFeedSecond() throws IOException {
489+
_oldGtfs.putLines(
490+
"agency.txt",
491+
"agency_id,agency_name,agency_url,agency_timezone",
492+
"1,Metro,http://metro.gov/,America/Los_Angeles");
493+
_oldGtfs.putLines(
494+
"stops.txt",
495+
"stop_id,stop_name,stop_lat,stop_lon,location_type",
496+
"100,Stop 100,47.654403,-122.305211,1");
497+
_oldGtfs.putLines("routes.txt", "route_id,route_short_name,route_long_name,route_type", "");
498+
_oldGtfs.putLines(
499+
"calendar.txt",
500+
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date",
501+
"");
502+
_oldGtfs.putLines("trips.txt", "route_id,service_id,trip_id", "");
503+
_oldGtfs.putLines(
504+
"stop_times.txt", "trip_id,stop_id,stop_sequence,arrival_time,departure_time", "");
505+
506+
_newGtfs.putLines(
507+
"agency.txt",
508+
"agency_id,agency_name,agency_url,agency_timezone",
509+
"1,Metro,http://metro.gov/,America/Los_Angeles");
510+
_newGtfs.putLines(
511+
"stops.txt",
512+
"stop_id,stop_name,stop_lat,stop_lon,location_type",
513+
"100,Stop 100,47.654403,-122.305211,0");
514+
_newGtfs.putLines(
515+
"routes.txt", "route_id,route_short_name,route_long_name,route_type", "R1,1,Route One,3");
516+
_newGtfs.putLines(
517+
"calendar.txt",
518+
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date",
519+
"sid0,1,1,1,1,1,0,0,20120101,20121231");
520+
_newGtfs.putLines("trips.txt", "route_id,service_id,trip_id", "R1,sid0,T1");
521+
_newGtfs.putLines(
522+
"stop_times.txt",
523+
"trip_id,stop_id,stop_sequence,arrival_time,departure_time",
524+
"T1,100,0,08:00:00,08:00:00");
525+
526+
StopMergeStrategy stopStrategy = new StopMergeStrategy();
527+
stopStrategy.setDuplicateDetectionStrategy(EDuplicateDetectionStrategy.IDENTITY);
528+
_merger.setStopStrategy(stopStrategy);
529+
530+
GtfsRelationalDao dao = merge();
531+
532+
assertEquals(
533+
2,
534+
dao.getAllStops().size(),
535+
"stops with incompatible location_type must not collapse into one Stop; the station stop"
536+
+ " should be kept as a separate, renamed entity");
537+
538+
boolean foundStopTime = false;
539+
for (Trip trip : dao.getAllTrips()) {
540+
for (StopTime st : dao.getStopTimesForTrip(trip)) {
541+
foundStopTime = true;
542+
Stop mergedStop = (Stop) st.getStop();
543+
assertEquals(
544+
0,
545+
mergedStop.getLocationType(),
546+
"GTFS spec: trips must reference location_type=0 stops, not stations (location_type="
547+
+ mergedStop.getLocationType()
548+
+ ")");
549+
}
550+
}
551+
assertTrue(foundStopTime, "expected at least one merged stop_time");
552+
}
553+
554+
/**
555+
* Same reproduction as {@link #testLocationTypeMismatch_StationFeedFirstPlatformFeedSecond}, with
556+
* input order reversed: platform feed listed first (_oldGtfs), station feed listed second
557+
* (_newGtfs). GtfsMerger processes the LAST listed feed first, so the station feed's stop
558+
* (location_type=1) is registered as the target stop first. When the platform feed's stop
559+
* (location_type=0, which owns the StopTime) is processed,
560+
* StopMergeStrategy#rejectDuplicateOverDifferences rejects it as a duplicate due to the
561+
* location_type mismatch, so it is kept as a separate, renamed Stop and the StopTime keeps
562+
* referencing it rather than being repointed onto the station.
563+
*/
564+
@Test
565+
public void testLocationTypeMismatch_PlatformFeedFirstStationFeedSecond() throws IOException {
566+
_oldGtfs.putLines(
567+
"agency.txt",
568+
"agency_id,agency_name,agency_url,agency_timezone",
569+
"1,Metro,http://metro.gov/,America/Los_Angeles");
570+
_oldGtfs.putLines(
571+
"stops.txt",
572+
"stop_id,stop_name,stop_lat,stop_lon,location_type",
573+
"100,Stop 100,47.654403,-122.305211,0");
574+
_oldGtfs.putLines(
575+
"routes.txt", "route_id,route_short_name,route_long_name,route_type", "R1,1,Route One,3");
576+
_oldGtfs.putLines(
577+
"calendar.txt",
578+
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date",
579+
"sid0,1,1,1,1,1,0,0,20120101,20121231");
580+
_oldGtfs.putLines("trips.txt", "route_id,service_id,trip_id", "R1,sid0,T1");
581+
_oldGtfs.putLines(
582+
"stop_times.txt",
583+
"trip_id,stop_id,stop_sequence,arrival_time,departure_time",
584+
"T1,100,0,08:00:00,08:00:00");
585+
586+
_newGtfs.putLines(
587+
"agency.txt",
588+
"agency_id,agency_name,agency_url,agency_timezone",
589+
"1,Metro,http://metro.gov/,America/Los_Angeles");
590+
_newGtfs.putLines(
591+
"stops.txt",
592+
"stop_id,stop_name,stop_lat,stop_lon,location_type",
593+
"100,Stop 100,47.654403,-122.305211,1");
594+
_newGtfs.putLines("routes.txt", "route_id,route_short_name,route_long_name,route_type", "");
595+
_newGtfs.putLines(
596+
"calendar.txt",
597+
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date",
598+
"");
599+
_newGtfs.putLines("trips.txt", "route_id,service_id,trip_id", "");
600+
_newGtfs.putLines(
601+
"stop_times.txt", "trip_id,stop_id,stop_sequence,arrival_time,departure_time", "");
602+
603+
StopMergeStrategy stopStrategy = new StopMergeStrategy();
604+
stopStrategy.setDuplicateDetectionStrategy(EDuplicateDetectionStrategy.IDENTITY);
605+
_merger.setStopStrategy(stopStrategy);
606+
607+
GtfsRelationalDao dao = merge();
608+
609+
assertEquals(
610+
2,
611+
dao.getAllStops().size(),
612+
"stops with incompatible location_type must not collapse into one Stop; the platform stop"
613+
+ " should be kept as a separate, renamed entity");
614+
615+
boolean foundStopTime = false;
616+
for (Trip trip : dao.getAllTrips()) {
617+
for (StopTime st : dao.getStopTimesForTrip(trip)) {
618+
foundStopTime = true;
619+
Stop mergedStop = (Stop) st.getStop();
620+
assertEquals(
621+
0,
622+
mergedStop.getLocationType(),
623+
"GTFS spec: trips must reference location_type=0 stops, not stations (location_type="
624+
+ mergedStop.getLocationType()
625+
+ ")");
626+
}
627+
}
628+
assertTrue(foundStopTime, "expected at least one merged stop_time");
629+
}
630+
474631
private GtfsRelationalDao merge() throws IOException {
475632
List<File> paths = new ArrayList<>();
476633
paths.add(_oldGtfs.getPath());
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Copyright (C) 2012 Google, Inc.
3+
*
4+
* <p>Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
5+
* except in compliance with the License. You may obtain a copy of the License at
6+
*
7+
* <p>http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* <p>Unless required by applicable law or agreed to in writing, software distributed under the
10+
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
* express or implied. See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package org.onebusaway.gtfs_merge;
15+
16+
import static org.junit.jupiter.api.Assertions.assertEquals;
17+
18+
import java.io.File;
19+
import java.io.IOException;
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
import org.junit.jupiter.api.BeforeEach;
23+
import org.junit.jupiter.api.Test;
24+
import org.onebusaway.gtfs.impl.GtfsRelationalDaoImpl;
25+
import org.onebusaway.gtfs.model.Stop;
26+
import org.onebusaway.gtfs.model.StopTime;
27+
import org.onebusaway.gtfs.serialization.GtfsReader;
28+
import org.onebusaway.gtfs.services.GtfsRelationalDao;
29+
import org.onebusaway.gtfs.services.MockGtfs;
30+
31+
/**
32+
* Reproduces <a href="https://github.com/OneBusAway/onebusaway-gtfs-modules/issues/409">issue
33+
* #409</a> through the real production merge path ({@link GtfsMerger#run(List, File)}), not an
34+
* isolated call to a single merge strategy.
35+
*
36+
* <p>Two feeds publish the same stop_id, but disagree on location_type: one models it as a platform
37+
* (location_type=0) that a trip actually visits, the other models it as a station
38+
* (location_type=1). Per the GTFS spec, a stop_time may only reference a stop (location_type=0),
39+
* never a station. StopMergeStrategy#rejectDuplicateOverDifferences rejects a same-id pair whose
40+
* location_type differs, so the two entities are never merged into one Stop; instead, whichever one
41+
* is loaded second is renamed and kept as a separate entity, and stop_times always keep referencing
42+
* their original location_type=0 stop.
43+
*/
44+
public class StopLocationTypeMergeTest {
45+
46+
private static final String STOP_ID = "1000";
47+
48+
/** location_type=0, visited by trip T1 via a stop_time - this is the spec-valid feed. */
49+
private MockGtfs _platformFeed;
50+
51+
/** location_type=1, same stop_id and same location, visited by no trip of its own. */
52+
private MockGtfs _stationFeed;
53+
54+
@BeforeEach
55+
public void before() throws IOException {
56+
_platformFeed = MockGtfs.create();
57+
_platformFeed.putLines(
58+
"agency.txt",
59+
"agency_id,agency_name,agency_url,agency_timezone",
60+
"1,Agency,http://agency.example/,America/Los_Angeles");
61+
_platformFeed.putLines(
62+
"routes.txt",
63+
"agency_id,route_id,route_short_name,route_long_name,route_type",
64+
"1,R1,1,Route One,3");
65+
_platformFeed.putLines(
66+
"stops.txt",
67+
"stop_id,stop_name,stop_lat,stop_lon,location_type",
68+
STOP_ID + ",Main St,47.6,-122.3,0");
69+
_platformFeed.putLines(
70+
"calendars.txt",
71+
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date",
72+
"sid0,1,1,1,1,1,1,1,20250101,20251231");
73+
_platformFeed.putLines("trips.txt", "route_id,service_id,trip_id", "R1,sid0,T1");
74+
_platformFeed.putLines(
75+
"stop_times.txt",
76+
"trip_id,stop_id,stop_sequence,arrival_time,departure_time",
77+
"T1," + STOP_ID + ",0,08:00:00,08:00:00");
78+
79+
_stationFeed = MockGtfs.create();
80+
_stationFeed.putLines(
81+
"agency.txt",
82+
"agency_id,agency_name,agency_url,agency_timezone",
83+
"1,Agency,http://agency.example/,America/Los_Angeles");
84+
_stationFeed.putLines(
85+
"routes.txt", "agency_id,route_id,route_short_name,route_long_name,route_type", "");
86+
_stationFeed.putLines(
87+
"stops.txt",
88+
"stop_id,stop_name,stop_lat,stop_lon,location_type",
89+
STOP_ID + ",Main St,47.6,-122.3,1");
90+
_stationFeed.putLines(
91+
"calendars.txt",
92+
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date",
93+
"");
94+
_stationFeed.putLines("trips.txt", "route_id,service_id,trip_id", "");
95+
_stationFeed.putLines(
96+
"stop_times.txt", "trip_id,stop_id,stop_sequence,arrival_time,departure_time", "");
97+
}
98+
99+
/**
100+
* Station feed is listed LAST. GtfsMerger.run() walks the input path list in reverse, so the
101+
* last-listed feed is loaded into the empty merge target first and would win any stop_id conflict
102+
* (see GtfsMerger#run, and the "lowest priority feed (first) to highest priority feed (last)"
103+
* convention documented in GtfsMergerTest) -- except that the differing location_type causes the
104+
* platform feed's stop to be rejected as a duplicate and kept as a separate, renamed Stop
105+
* instead.
106+
*/
107+
@Test
108+
public void testStationFeedListedLast_stopTimeEndsUpOnStation() throws IOException {
109+
GtfsRelationalDao dao = merge(_platformFeed, _stationFeed);
110+
111+
assertEquals(
112+
2,
113+
dao.getAllStops().size(),
114+
"stops with incompatible location_type must not collapse into one Stop; the platform stop"
115+
+ " should be kept as a separate, renamed entity");
116+
StopTime stopTime = onlyStopTime(dao);
117+
Stop mergedStop = (Stop) stopTime.getStop();
118+
119+
// EXPECTED (GTFS spec): a trip's stop_time must reference a stop (location_type=0),
120+
// never a station (location_type=1).
121+
assertEquals(
122+
Stop.LOCATION_TYPE_STOP,
123+
mergedStop.getLocationType(),
124+
"issue #409: StopTime for trip T1 must reference the platform (location_type=0), not"
125+
+ " the station (location_type=1) it was merged into");
126+
}
127+
128+
/**
129+
* Same two feeds, order reversed: platform feed now listed last (highest priority), so it is the
130+
* one loaded into the target first this time. Included to show that the outcome for a fixed pair
131+
* of feeds flips depending purely on caller-supplied file order, not on location_type - i.e.
132+
* there is no rule anywhere in the merge path that gives stations priority over stops, or vice
133+
* versa.
134+
*/
135+
@Test
136+
public void testPlatformFeedListedLast_stopTimeStaysOnPlatform() throws IOException {
137+
GtfsRelationalDao dao = merge(_stationFeed, _platformFeed);
138+
139+
assertEquals(
140+
2,
141+
dao.getAllStops().size(),
142+
"stops with incompatible location_type must not collapse into one Stop; the station stop"
143+
+ " should be kept as a separate, renamed entity");
144+
StopTime stopTime = onlyStopTime(dao);
145+
Stop mergedStop = (Stop) stopTime.getStop();
146+
147+
assertEquals(
148+
Stop.LOCATION_TYPE_STOP,
149+
mergedStop.getLocationType(),
150+
"StopTime for trip T1 should reference the platform (location_type=0)");
151+
}
152+
153+
private StopTime onlyStopTime(GtfsRelationalDao dao) {
154+
assertEquals(1, dao.getAllStopTimes().size(), "expected exactly one merged stop_time");
155+
return dao.getAllStopTimes().iterator().next();
156+
}
157+
158+
/** Runs the real GtfsMerger.run() production pipeline over the given feeds, in order. */
159+
private GtfsRelationalDao merge(MockGtfs first, MockGtfs second) throws IOException {
160+
List<File> paths = new ArrayList<>();
161+
paths.add(first.getPath());
162+
paths.add(second.getPath());
163+
164+
MockGtfs mergedGtfs = MockGtfs.create();
165+
GtfsMerger merger = new GtfsMerger(false);
166+
merger.run(paths, mergedGtfs.getPath());
167+
168+
GtfsReader reader = new GtfsReader();
169+
GtfsRelationalDaoImpl dao = new GtfsRelationalDaoImpl();
170+
reader.setEntityStore(dao);
171+
reader.setInputLocation(mergedGtfs.getPath());
172+
reader.run();
173+
return dao;
174+
}
175+
}

0 commit comments

Comments
 (0)