From 4463d394c54e0760bf8bc35faf69156b2c243774 Mon Sep 17 00:00:00 2001 From: PatrickSteil Date: Sat, 28 Mar 2026 14:05:25 +0100 Subject: [PATCH 1/5] Add new Validator: checks trip headsign along trip --- .../validator/TripHeadsignValidator.java | 135 ++++++++++++++++++ .../validator/NoticeFieldsTest.java | 1 + .../validator/TripHeadsignValidatorTest.java | 129 +++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java create mode 100644 main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidatorTest.java diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java new file mode 100644 index 0000000000..3f7e185b6a --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java @@ -0,0 +1,135 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.WARNING; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import javax.inject.Inject; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.FileRefs; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsStop; +import org.mobilitydata.gtfsvalidator.table.GtfsStopSchema; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeSchema; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsTrip; +import org.mobilitydata.gtfsvalidator.table.GtfsTripSchema; +import org.mobilitydata.gtfsvalidator.table.GtfsTripTableContainer; + +/** + * Validates that the trip headsign does not match the name of any intermediate stop (i.e., any stop + * that is not the last stop of the trip). + * + *

Generated notice: {@link TripHeadsignMatchesIntermediateStopNotice}. + */ +@GtfsValidator +public class TripHeadsignValidator extends FileValidator { + private final GtfsTripTableContainer tripTable; + private final GtfsStopTimeTableContainer stopTimeTable; + private final GtfsStopTableContainer stopTable; + + @Inject + TripHeadsignValidator( + GtfsTripTableContainer tripTable, + GtfsStopTimeTableContainer stopTimeTable, + GtfsStopTableContainer stopTable) { + this.tripTable = tripTable; + this.stopTimeTable = stopTimeTable; + this.stopTable = stopTable; + } + + @Override + public void validate(NoticeContainer noticeContainer) { + for (GtfsTrip trip : tripTable.getEntities()) { + if (!trip.hasTripHeadsign()) { + continue; + } + String headsign = trip.tripHeadsign(); + String tripId = trip.tripId(); + + List stopTimes = stopTimeTable.byTripId(tripId); + if (stopTimes.size() < 2) { + continue; // Not enough stops to have an intermediate stop + } + + // Sort by stop_sequence to find the true last stop + List sorted = + stopTimes.stream() + .sorted(Comparator.comparingInt(GtfsStopTime::stopSequence)) + .collect(Collectors.toList()); + + String lastStopId = sorted.get(sorted.size() - 1).stopId(); + + // Check all stops except the last + for (int i = 0; i < sorted.size() - 1; i++) { + GtfsStopTime intermediateStopTime = sorted.get(i); + String stopId = intermediateStopTime.stopId(); + Optional stop = stopTable.byStopId(stopId); + if (stop.isPresent() + && stop.get().hasStopName() + && stop.get().stopName().equalsIgnoreCase(headsign)) { + noticeContainer.addValidationNotice( + new TripHeadsignMatchesIntermediateStopNotice( + trip.csvRowNumber(), + tripId, + headsign, + stopId, + intermediateStopTime.stopSequence(), + lastStopId)); + } + } + } + } + + /** + * Trip headsign matches the name of an intermediate stop, not the last stop. + * + *

The `trip_headsign` matches the `stop_name` of a stop that is not the last stop of the trip. + * This may confuse passengers boarding after that stop, since the headsign suggests the vehicle + * is heading to a stop it has already passed. + */ + @GtfsValidationNotice( + severity = WARNING, + files = @FileRefs({GtfsTripSchema.class, GtfsStopTimeSchema.class, GtfsStopSchema.class})) + static class TripHeadsignMatchesIntermediateStopNotice extends ValidationNotice { + + /** The row number of the faulty record in `trips.txt`. */ + private final int csvRowNumber; + + /** The id of the trip with the problematic headsign. */ + private final String tripId; + + /** The headsign value that matches an intermediate stop name. */ + private final String tripHeadsign; + + /** The id of the intermediate stop whose name matches the headsign. */ + private final String stopId1; + + /** The stop_sequence value of the intermediate stop that matches the headsign. */ + private final int stopSequence; + + /** The id of the actual last stop of the trip. */ + private final String stopId2; + + TripHeadsignMatchesIntermediateStopNotice( + int csvRowNumber, + String tripId, + String tripHeadsign, + String stopId1, + int stopSequence, + String stopId2) { + this.csvRowNumber = csvRowNumber; + this.tripId = tripId; + this.tripHeadsign = tripHeadsign; + this.stopId1 = stopId1; + this.stopSequence = stopSequence; + this.stopId2 = stopId2; + } + } +} diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java index 1bbcb90a17..7a6840a1f0 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java @@ -231,6 +231,7 @@ public void testNoticeClassFieldNames() { "transferCount", "tripCsvRowNumber", "tripFieldName", + "tripHeadsign", "tripId", "tripIdA", "tripIdB", diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidatorTest.java new file mode 100644 index 0000000000..eca5a56166 --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidatorTest.java @@ -0,0 +1,129 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import java.util.List; +import org.junit.Test; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsStop; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsTrip; +import org.mobilitydata.gtfsvalidator.table.GtfsTripTableContainer; +import org.mobilitydata.gtfsvalidator.validator.TripHeadsignValidator.TripHeadsignMatchesIntermediateStopNotice; + +public class TripHeadsignValidatorTest { + + @Test + public void headsignMatchingLastStopShouldNotGenerateNotice() { + assertThat( + generateNotices( + ImmutableList.of(createTrip(1, "r1", "s1", "t0", "Central Station")), + ImmutableList.of( + createStopTime(0, "t0", "stop_a", 1), + createStopTime(0, "t0", "stop_b", 2), + createStopTime(0, "t0", "stop_central", 3)), + ImmutableList.of( + createStop("stop_a", "Airport"), + createStop("stop_b", "City Hall"), + createStop("stop_central", "Central Station")))) + .isEmpty(); + } + + @Test + public void headsignMatchingIntermediateStopShouldGenerateNotice() { + assertThat( + generateNotices( + ImmutableList.of(createTrip(1, "r1", "s1", "t0", "City Hall")), + ImmutableList.of( + createStopTime(0, "t0", "stop_a", 1), + createStopTime(0, "t0", "stop_b", 2), + createStopTime(0, "t0", "stop_central", 3)), + ImmutableList.of( + createStop("stop_a", "Airport"), + createStop("stop_b", "City Hall"), + createStop("stop_central", "Central Station")))) + .containsExactly( + new TripHeadsignMatchesIntermediateStopNotice( + 1, "t0", "City Hall", "stop_b", 2, "stop_central")); + } + + @Test + public void tripWithNoHeadsignShouldNotGenerateNotice() { + assertThat( + generateNotices( + ImmutableList.of(createTrip(1, "r1", "s1", "t0", null)), + ImmutableList.of( + createStopTime(0, "t0", "stop_a", 1), createStopTime(0, "t0", "stop_b", 2)), + ImmutableList.of( + createStop("stop_a", "Airport"), createStop("stop_b", "City Hall")))) + .isEmpty(); + } + + @Test + public void tripWithSingleStopShouldNotGenerateNotice() { + assertThat( + generateNotices( + ImmutableList.of(createTrip(1, "r1", "s1", "t0", "Airport")), + ImmutableList.of(createStopTime(0, "t0", "stop_a", 1)), + ImmutableList.of(createStop("stop_a", "Airport")))) + .isEmpty(); + } + + @Test + public void headsignMatchingFirstStopOfMultiStopTripShouldGenerateNotice() { + assertThat( + generateNotices( + ImmutableList.of(createTrip(1, "r1", "s1", "t0", "Airport")), + ImmutableList.of( + createStopTime(0, "t0", "stop_a", 1), + createStopTime(0, "t0", "stop_b", 2), + createStopTime(0, "t0", "stop_c", 3)), + ImmutableList.of( + createStop("stop_a", "Airport"), + createStop("stop_b", "City Hall"), + createStop("stop_c", "Central Station")))) + .containsExactly( + new TripHeadsignMatchesIntermediateStopNotice( + 1, "t0", "Airport", "stop_a", 1, "stop_c")); + } + + private static List generateNotices( + List trips, List stopTimes, List stops) { + NoticeContainer noticeContainer = new NoticeContainer(); + new TripHeadsignValidator( + GtfsTripTableContainer.forEntities(trips, noticeContainer), + GtfsStopTimeTableContainer.forEntities(stopTimes, noticeContainer), + GtfsStopTableContainer.forEntities(stops, noticeContainer)) + .validate(noticeContainer); + return noticeContainer.getValidationNotices(); + } + + private static GtfsTrip createTrip( + int csvRowNumber, String routeId, String serviceId, String tripId, String tripHeadsign) { + return new GtfsTrip.Builder() + .setCsvRowNumber(csvRowNumber) + .setRouteId(routeId) + .setServiceId(serviceId) + .setTripId(tripId) + .setTripHeadsign(tripHeadsign) + .build(); + } + + private static GtfsStopTime createStopTime( + int csvRowNumber, String tripId, String stopId, int stopSequence) { + return new GtfsStopTime.Builder() + .setCsvRowNumber(csvRowNumber) + .setTripId(tripId) + .setStopId(stopId) + .setStopSequence(stopSequence) + .build(); + } + + private static GtfsStop createStop(String stopId, String stopName) { + return new GtfsStop.Builder().setStopId(stopId).setStopName(stopName).build(); + } +} From e1df9150f46947e83befaea9f5c56a45f9821e8e Mon Sep 17 00:00:00 2001 From: Patrick Steil Date: Fri, 24 Apr 2026 16:23:46 +0200 Subject: [PATCH 2/5] change to INFO --- .../gtfsvalidator/validator/TripHeadsignValidator.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java index 3f7e185b6a..56809703bc 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java @@ -1,6 +1,6 @@ package org.mobilitydata.gtfsvalidator.validator; -import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.WARNING; +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.INFO; import java.util.Comparator; import java.util.List; @@ -95,7 +95,7 @@ public void validate(NoticeContainer noticeContainer) { * is heading to a stop it has already passed. */ @GtfsValidationNotice( - severity = WARNING, + severity = INFO, files = @FileRefs({GtfsTripSchema.class, GtfsStopTimeSchema.class, GtfsStopSchema.class})) static class TripHeadsignMatchesIntermediateStopNotice extends ValidationNotice { From d0b3fa9ef433a45d057022d8517d04da04c0b89b Mon Sep 17 00:00:00 2001 From: Patrick Steil Date: Sat, 25 Apr 2026 17:59:11 +0200 Subject: [PATCH 3/5] changed csv row number to long && more tests --- .../validator/TripHeadsignValidator.java | 4 +- .../validator/TripHeadsignValidatorTest.java | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java index 56809703bc..ac53394220 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java @@ -100,7 +100,7 @@ public void validate(NoticeContainer noticeContainer) { static class TripHeadsignMatchesIntermediateStopNotice extends ValidationNotice { /** The row number of the faulty record in `trips.txt`. */ - private final int csvRowNumber; + private final long csvRowNumber; /** The id of the trip with the problematic headsign. */ private final String tripId; @@ -118,7 +118,7 @@ static class TripHeadsignMatchesIntermediateStopNotice extends ValidationNotice private final String stopId2; TripHeadsignMatchesIntermediateStopNotice( - int csvRowNumber, + long csvRowNumber, String tripId, String tripHeadsign, String stopId1, diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidatorTest.java index eca5a56166..0e02a03e2f 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidatorTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidatorTest.java @@ -73,6 +73,44 @@ public void tripWithSingleStopShouldNotGenerateNotice() { .isEmpty(); } + @Test + public void multipleIntermediateStopsMatchingHeadsignShouldGenerateOneNoticeEach() { + // Both stop_a and stop_b share the same name as the headsign. The validator checks every + // intermediate stop independently, so a separate notice should fire for each match. + assertThat( + generateNotices( + ImmutableList.of(createTrip(1, "r1", "s1", "t0", "City Hall")), + ImmutableList.of( + createStopTime(0, "t0", "stop_a", 1), + createStopTime(0, "t0", "stop_b", 2), + createStopTime(0, "t0", "stop_c", 3)), + ImmutableList.of( + createStop("stop_a", "City Hall"), + createStop("stop_b", "City Hall"), + createStop("stop_c", "Central Station")))) + .containsExactly( + new TripHeadsignMatchesIntermediateStopNotice( + 1, "t0", "City Hall", "stop_a", 1, "stop_c"), + new TripHeadsignMatchesIntermediateStopNotice( + 1, "t0", "City Hall", "stop_b", 2, "stop_c")); + } + + @Test + public void intermediateStopAbsentFromStopsTableShouldNotGenerateNotice() { + // When a stop_id referenced in stop_times.txt does not exist in stops.txt the validator's + // stopTable.byStopId() returns empty. The broken foreign key is reported by a separate rule; + // this validator should simply skip the missing stop rather than crash or emit a false notice. + assertThat( + generateNotices( + ImmutableList.of(createTrip(1, "r1", "s1", "t0", "Ghost Stop")), + ImmutableList.of( + createStopTime(0, "t0", "stop_ghost", 1), createStopTime(0, "t0", "stop_b", 2)), + ImmutableList.of( + // stop_ghost is intentionally absent from the stops table + createStop("stop_b", "Central Station")))) + .isEmpty(); + } + @Test public void headsignMatchingFirstStopOfMultiStopTripShouldGenerateNotice() { assertThat( From 35c750e345ebcc65c2259fe5db96b30c724926b8 Mon Sep 17 00:00:00 2001 From: Patrick Steil Date: Tue, 28 Apr 2026 16:12:35 +0200 Subject: [PATCH 4/5] removed sorting, as stopTimes is already sorted --- .../validator/TripHeadsignValidator.java | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java index ac53394220..fe11123f1c 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java @@ -58,17 +58,12 @@ public void validate(NoticeContainer noticeContainer) { continue; // Not enough stops to have an intermediate stop } - // Sort by stop_sequence to find the true last stop - List sorted = - stopTimes.stream() - .sorted(Comparator.comparingInt(GtfsStopTime::stopSequence)) - .collect(Collectors.toList()); - - String lastStopId = sorted.get(sorted.size() - 1).stopId(); + // stopTimes are already sorted + String lastStopId = stopTimes.get(stopTimes.size() - 1).stopId(); // Check all stops except the last - for (int i = 0; i < sorted.size() - 1; i++) { - GtfsStopTime intermediateStopTime = sorted.get(i); + for (int i = 0; i < stopTimes.size() - 1; i++) { + GtfsStopTime intermediateStopTime = stopTimes.get(i); String stopId = intermediateStopTime.stopId(); Optional stop = stopTable.byStopId(stopId); if (stop.isPresent() From 095f4c24a7aabea680ecca74690c84c0a6a07ce5 Mon Sep 17 00:00:00 2001 From: Patrick Steil Date: Tue, 28 Apr 2026 16:17:26 +0200 Subject: [PATCH 5/5] forgot to spotlessApply --- .../gtfsvalidator/validator/TripHeadsignValidator.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java index fe11123f1c..38176f16fa 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java @@ -2,10 +2,8 @@ import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.INFO; -import java.util.Comparator; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; import javax.inject.Inject; import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.FileRefs;