diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeature.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeature.java index a7e75d4ab5..9dcf198052 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeature.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeature.java @@ -2,7 +2,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; -import org.locationtech.jts.geom.Polygonal; +import org.locationtech.jts.geom.Geometry; import org.mobilitydata.gtfsvalidator.util.geojson.GeometryType; /** This class contains the information from one feature in the GeoJSON file. */ @@ -25,12 +25,20 @@ public final class GtfsGeoJsonFeature implements GtfsEntity { private String featureId; // The id of a feature in the GeoJSON file. private GeometryType geometryType; // The type of the geometry. - private Polygonal geometryDefinition; // The geometry of the feature. + private Geometry geometryDefinition; // The geometry of the feature. private String stopName; // The name of the location as displayed to the riders. private String stopDesc; // A description of the location. public GtfsGeoJsonFeature() {} + private GtfsGeoJsonFeature(Builder builder) { + this.featureId = builder.featureId; + this.geometryType = builder.geometryType; + this.geometryDefinition = builder.geometryDefinition; + this.stopName = builder.stopName; + this.stopDesc = builder.stopDesc; + } + // TODO: Change the interface hierarchy so we dont need this. It's not relevant for geojson @Override public int csvRowNumber() { @@ -50,15 +58,22 @@ public void setFeatureId(@Nullable String featureId) { this.featureId = featureId; } - public Polygonal geometryDefinition() { + public Geometry geometryDefinition() { return geometryDefinition; } + public Boolean geometryOverlaps(GtfsGeoJsonFeature other) { + if (geometryDefinition == null || other.geometryDefinition == null) { + return false; + } + return geometryDefinition.overlaps(other.geometryDefinition); + } + public Boolean hasGeometryDefinition() { return geometryDefinition != null; } - public void setGeometryDefinition(Polygonal polygon) { + public void setGeometryDefinition(Geometry polygon) { this.geometryDefinition = polygon; } @@ -97,4 +112,42 @@ public Boolean hasStopDesc() { public void setStopDesc(@Nullable String stopDesc) { this.stopDesc = stopDesc; } + + /** Builder class for GtfsGeoJsonFeature. */ + public static class Builder { + private String featureId; + private GeometryType geometryType; + private Geometry geometryDefinition; + private String stopName; + private String stopDesc; + + public Builder featureId(String featureId) { + this.featureId = featureId; + return this; + } + + public Builder geometryType(GeometryType geometryType) { + this.geometryType = geometryType; + return this; + } + + public Builder geometryDefinition(Geometry geometryDefinition) { + this.geometryDefinition = geometryDefinition; + return this; + } + + public Builder stopName(String stopName) { + this.stopName = stopName; + return this; + } + + public Builder stopDesc(String stopDesc) { + this.stopDesc = stopDesc; + return this; + } + + public GtfsGeoJsonFeature build() { + return new GtfsGeoJsonFeature(this); + } + } } diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeaturesContainer.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeaturesContainer.java index bdf9cea7cb..4e9d4fc36d 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeaturesContainer.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsGeoJsonFeaturesContainer.java @@ -86,4 +86,12 @@ private void setupIndices(NoticeContainer noticeContainer) { // } } } + + public Map byLocationIdMap() { + return byLocationIdMap; + } + + public GtfsGeoJsonFeature byLocationId(String locationId) { + return byLocationIdMap.get(locationId); + } } diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/OverlappingPickupDropOffZoneValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/OverlappingPickupDropOffZoneValidator.java new file mode 100644 index 0000000000..c17f159be9 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/OverlappingPickupDropOffZoneValidator.java @@ -0,0 +1,171 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.*; +import org.mobilitydata.gtfsvalidator.type.GtfsTime; + +@GtfsValidator +public class OverlappingPickupDropOffZoneValidator extends FileValidator { + + private final GtfsStopTimeTableContainer stopTimeTableContainer; + private final GtfsGeoJsonFeaturesContainer geoJsonFeaturesContainer; + + @Inject + OverlappingPickupDropOffZoneValidator( + GtfsStopTimeTableContainer table, GtfsGeoJsonFeaturesContainer geoJsonFeaturesContainer) { + this.stopTimeTableContainer = table; + this.geoJsonFeaturesContainer = geoJsonFeaturesContainer; + } + + @Override + public void validate(NoticeContainer noticeContainer) { + // If either the stop_times file or GeoJSON file is missing, skip validation. + if (stopTimeTableContainer.isMissingFile() || geoJsonFeaturesContainer.isMissingFile()) { + return; + } + + // Iterate through all stop times grouped by trip ID. + for (Map.Entry> entry : + stopTimeTableContainer.byTripIdMap().asMap().entrySet()) { + List stopTimesForTrip = new ArrayList<>(entry.getValue()); + + // Compare each pair of stop times within the same trip. + for (int i = 0; i < stopTimesForTrip.size(); i++) { + GtfsStopTime stopTime1 = stopTimesForTrip.get(i); + for (int j = i + 1; j < stopTimesForTrip.size(); j++) { + GtfsStopTime stopTime2 = stopTimesForTrip.get(j); + + // Skip validation if the two stop times have different pickup/drop-off types or if + // the types are UNRECOGNIZED. + if ((stopTime1.pickupType() != stopTime2.pickupType() + && stopTime1.dropOffType() != stopTime2.dropOffType()) + || (stopTime1.pickupType() == GtfsPickupDropOff.UNRECOGNIZED + || stopTime1.dropOffType() == GtfsPickupDropOff.UNRECOGNIZED + || stopTime2.pickupType() == GtfsPickupDropOff.UNRECOGNIZED + || stopTime2.dropOffType() == GtfsPickupDropOff.UNRECOGNIZED)) { + continue; + } + + // Skip validation if any required fields are missing in either stop time. + if (!(stopTime1.hasEndPickupDropOffWindow() + && stopTime1.hasStartPickupDropOffWindow() + && stopTime2.hasEndPickupDropOffWindow() + && stopTime2.hasStartPickupDropOffWindow() + && stopTime1.hasLocationId() + && stopTime2.hasLocationId())) { + continue; + } + + // Skip validation if both stop times reference the same location. + if (stopTime1.locationId().equals(stopTime2.locationId())) { + continue; + } + + // Skip validation if the pickup/drop-off windows of the two stop times do not overlap. + if (stopTime1.startPickupDropOffWindow().isAfter(stopTime2.endPickupDropOffWindow()) + || stopTime1.endPickupDropOffWindow().isBefore(stopTime2.startPickupDropOffWindow()) + || stopTime1.endPickupDropOffWindow().equals(stopTime2.startPickupDropOffWindow()) + || stopTime1.startPickupDropOffWindow().equals(stopTime2.endPickupDropOffWindow())) { + continue; + } + + // Retrieve GeoJSON features for the locations referenced by the two stop times. + GtfsGeoJsonFeature stop1GeoJsonFeature = + geoJsonFeaturesContainer.byLocationId(stopTime1.locationId()); + GtfsGeoJsonFeature stop2GeoJsonFeature = + geoJsonFeaturesContainer.byLocationId(stopTime2.locationId()); + + // Skip validation if either location has no corresponding GeoJSON feature. + if (stop1GeoJsonFeature == null || stop2GeoJsonFeature == null) { + continue; + } + + // If the geometries of the two locations overlap, generate a validation notice. + if (stop1GeoJsonFeature.geometryOverlaps(stop2GeoJsonFeature)) { + noticeContainer.addValidationNotice( + new OverlappingZoneAndPickupDropOffWindowNotice( + stopTime1.tripId(), + stopTime1.stopSequence(), + stopTime1.locationId(), + stopTime1.startPickupDropOffWindow(), + stopTime1.endPickupDropOffWindow(), + stopTime2.stopSequence(), + stopTime2.locationId(), + stopTime2.startPickupDropOffWindow(), + stopTime2.endPickupDropOffWindow())); + } + } + } + } + } + + /** + * Two entities have overlapping pickup/drop-off windows and zones. + * + *

Two entities in `stop_times.txt` with the same `trip_id` have the same `pickup_type` or + * `drop_off_type`, overlapping pickup/drop-off windows and overlapping zones in + * `locations.geojson`. + */ + @GtfsValidationNotice( + severity = ERROR, + files = @GtfsValidationNotice.FileRefs({GtfsGeoJsonFeature.class, GtfsStopTime.class})) + static class OverlappingZoneAndPickupDropOffWindowNotice extends ValidationNotice { + /** The `trip_id` of the entities. */ + private final String tripId; + + /** The `stop_sequence` of the first entity in `stop_times.txt`. */ + private final Integer stopSequence1; + + /** The `location_id` of the first entity. */ + private final String locationId1; + + /** The `start_pickup_drop_off_window` of the first entity in `stop_times.txt`. */ + private final GtfsTime startPickupDropOffWindow1; + + /** The `end_pickup_drop_off_window` of the first entity in `stop_times.txt`. */ + private final GtfsTime endPickupDropOffWindow1; + + /** The `stop_sequence` of the second entity in `stop_times.txt`. */ + private final Integer stopSequence2; + + /** The `location_id` of the second entity. */ + private final String locationId2; + + /** The `start_pickup_drop_off_window` of the second entity in `stop_times.txt`. */ + private final GtfsTime startPickupDropOffWindow2; + + /** The `end_pickup_drop_off_window` of the second entity in `stop_times.txt`. */ + private final GtfsTime endPickupDropOffWindow2; + + OverlappingZoneAndPickupDropOffWindowNotice( + String tripId, + Integer stopSequence1, + String locationId1, + GtfsTime startPickupDropOffWindow1, + GtfsTime endPickupDropOffWindow1, + Integer stopSequence2, + String locationId2, + GtfsTime startPickupDropOffWindow2, + GtfsTime endPickupDropOffWindow2) { + this.tripId = tripId; + this.stopSequence1 = stopSequence1; + this.locationId1 = locationId1; + this.startPickupDropOffWindow1 = startPickupDropOffWindow1; + this.endPickupDropOffWindow1 = endPickupDropOffWindow1; + this.stopSequence2 = stopSequence2; + this.locationId2 = locationId2; + this.startPickupDropOffWindow2 = startPickupDropOffWindow2; + this.endPickupDropOffWindow2 = endPickupDropOffWindow2; + } + } +} 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 0507ec5147..0b3a147bd7 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java @@ -81,6 +81,8 @@ public void testNoticeClassFieldNames() { "distanceKm", "endFieldName", "endPickupDropOffWindow", + "endPickupDropOffWindow1", + "endPickupDropOffWindow2", "endValue", "entityCount", "entityId", @@ -121,6 +123,8 @@ public void testNoticeClassFieldNames() { "lineIndex", "locationGroupId", "locationId", + "locationId1", + "locationId2", "locationType", "locationTypeName", "locationTypeValue", @@ -184,6 +188,8 @@ public void testNoticeClassFieldNames() { "speedKph", "startFieldName", "startPickupDropOffWindow", + "startPickupDropOffWindow1", + "startPickupDropOffWindow2", "startValue", "stopCsvRowNumber", "stopDesc", diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/OverlappingPickupDropOffZoneValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/OverlappingPickupDropOffZoneValidatorTest.java new file mode 100644 index 0000000000..d6680ff53d --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/OverlappingPickupDropOffZoneValidatorTest.java @@ -0,0 +1,218 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.table.*; +import org.mobilitydata.gtfsvalidator.type.GtfsTime; +import org.mobilitydata.gtfsvalidator.util.geojson.GeometryType; + +@RunWith(JUnit4.class) +public class OverlappingPickupDropOffZoneValidatorTest { + @Test + public void overlappingPickupDropOffZonesShouldGenerateNotice() { + // If pickup and drop off windows overlap and zones overlap, a notice should be + // generated for stop times within the same trip + NoticeContainer noticeContainer = new NoticeContainer(); + GeometryFactory geometryFactory = new GeometryFactory(); + GtfsGeoJsonFileDescriptor descriptor = new GtfsGeoJsonFileDescriptor(); + ArrayList gtfsGeoJsonFeatures = + new ArrayList<>( + List.of( + new GtfsGeoJsonFeature.Builder() + .featureId("1") + .geometryDefinition( + geometryFactory.createPolygon( + new Coordinate[] { + new Coordinate(0, 0), + new Coordinate(4, 0), + new Coordinate(4, 4), + new Coordinate(0, 4), + new Coordinate(0, 0) // Close the polygon + })) + .geometryType(GeometryType.POLYGON) + .build(), + new GtfsGeoJsonFeature.Builder() + .featureId("2") + .geometryDefinition( + geometryFactory.createPolygon( + new Coordinate[] { + new Coordinate(2, 2), + new Coordinate(6, 2), + new Coordinate(6, 6), + new Coordinate(2, 6), + new Coordinate(2, 2) // Close the polygon + })) + .geometryType(GeometryType.POLYGON) + .build())); + GtfsGeoJsonFeaturesContainer geoJsonFeaturesContainer = + descriptor.createContainerForEntities(gtfsGeoJsonFeatures, noticeContainer); + + ArrayList stopTimes = + new ArrayList<>( + List.of( + new GtfsStopTime.Builder() + .setTripId("t0") + .setStopSequence(1) + .setLocationId("1") + .setPickupType(1) + .setStartPickupDropOffWindow(GtfsTime.fromString("05:00:00")) + .setEndPickupDropOffWindow(GtfsTime.fromString("07:00:00")) + .build(), + new GtfsStopTime.Builder() + .setTripId("t0") + .setStopSequence(2) + .setLocationId("2") + .setPickupType(1) + .setStartPickupDropOffWindow(GtfsTime.fromString("06:00:00")) + .setEndPickupDropOffWindow(GtfsTime.fromString("08:00:00")) + .build())); + GtfsStopTimeTableContainer stopTimeTableContainer = + GtfsStopTimeTableContainer.forEntities(stopTimes, noticeContainer); + OverlappingPickupDropOffZoneValidator validator = + new OverlappingPickupDropOffZoneValidator(stopTimeTableContainer, geoJsonFeaturesContainer); + validator.validate(noticeContainer); + assertThat(noticeContainer.getValidationNotices()).hasSize(1); + + assertThat(noticeContainer.getValidationNotices().iterator().next()) + .isInstanceOf( + OverlappingPickupDropOffZoneValidator.OverlappingZoneAndPickupDropOffWindowNotice + .class); + } + + @Test + public void nonOverlappingPickupDropOffWindowsShouldNotGenerateNotice() { + // If pickup and drop off windows do not overlap and zones overlap, a notice should not be + // generated for stop times within the same trip + NoticeContainer noticeContainer = new NoticeContainer(); + GeometryFactory geometryFactory = new GeometryFactory(); + GtfsGeoJsonFileDescriptor descriptor = new GtfsGeoJsonFileDescriptor(); + ArrayList gtfsGeoJsonFeatures = + new ArrayList<>( + List.of( + new GtfsGeoJsonFeature.Builder() + .featureId("1") + .geometryDefinition( + geometryFactory.createPolygon( + new Coordinate[] { + new Coordinate(0, 0), + new Coordinate(4, 0), + new Coordinate(4, 4), + new Coordinate(0, 4), + new Coordinate(0, 0) // Close the polygon + })) + .geometryType(GeometryType.POLYGON) + .build(), + new GtfsGeoJsonFeature.Builder() + .featureId("2") + .geometryDefinition( + geometryFactory.createPolygon( + new Coordinate[] { + new Coordinate(2, 2), + new Coordinate(6, 2), + new Coordinate(6, 6), + new Coordinate(2, 6), + new Coordinate(2, 2) // Close the polygon + })) + .geometryType(GeometryType.POLYGON) + .build())); + GtfsGeoJsonFeaturesContainer geoJsonFeaturesContainer = + descriptor.createContainerForEntities(gtfsGeoJsonFeatures, noticeContainer); + + ArrayList stopTimes = + new ArrayList<>( + List.of( + new GtfsStopTime.Builder() + .setTripId("t0") + .setStopSequence(1) + .setLocationId("1") + .setStartPickupDropOffWindow(GtfsTime.fromString("05:00:00")) + .setEndPickupDropOffWindow(GtfsTime.fromString("07:00:00")) + .build(), + new GtfsStopTime.Builder() + .setTripId("t0") + .setStopSequence(2) + .setLocationId("2") + .setStartPickupDropOffWindow(GtfsTime.fromString("07:00:00")) + .setEndPickupDropOffWindow(GtfsTime.fromString("08:00:00")) + .build())); + GtfsStopTimeTableContainer stopTimeTableContainer = + GtfsStopTimeTableContainer.forEntities(stopTimes, noticeContainer); + OverlappingPickupDropOffZoneValidator validator = + new OverlappingPickupDropOffZoneValidator(stopTimeTableContainer, geoJsonFeaturesContainer); + validator.validate(noticeContainer); + assertThat(noticeContainer.getValidationNotices()).hasSize(0); + } + + @Test + public void nonOverlappingPickupDropOffZonesShouldNotGenerateNotice() { + // If pickup and drop off windows overlap but zones do NOT overlap, no notice should be + // generated + NoticeContainer noticeContainer = new NoticeContainer(); + GeometryFactory geometryFactory = new GeometryFactory(); + GtfsGeoJsonFileDescriptor descriptor = new GtfsGeoJsonFileDescriptor(); + ArrayList gtfsGeoJsonFeatures = + new ArrayList<>( + List.of( + new GtfsGeoJsonFeature.Builder() + .featureId("1") + .geometryDefinition( + geometryFactory.createPolygon( + new Coordinate[] { + new Coordinate(0, 0), + new Coordinate(2, 0), + new Coordinate(2, 2), + new Coordinate(0, 2), + new Coordinate(0, 0) // Close the polygon + })) + .geometryType(GeometryType.POLYGON) + .build(), + new GtfsGeoJsonFeature.Builder() + .featureId("2") + .geometryDefinition( + geometryFactory.createPolygon( + new Coordinate[] { + new Coordinate(5, 5), + new Coordinate(7, 5), + new Coordinate(7, 7), + new Coordinate(5, 7), + new Coordinate(5, 5) // Close the polygon + })) + .geometryType(GeometryType.POLYGON) + .build())); + GtfsGeoJsonFeaturesContainer geoJsonFeaturesContainer = + descriptor.createContainerForEntities(gtfsGeoJsonFeatures, noticeContainer); + + ArrayList stopTimes = + new ArrayList<>( + List.of( + new GtfsStopTime.Builder() + .setTripId("t0") + .setStopSequence(1) + .setLocationId("1") + .setStartPickupDropOffWindow(GtfsTime.fromString("05:00:00")) + .setEndPickupDropOffWindow(GtfsTime.fromString("07:00:00")) + .build(), + new GtfsStopTime.Builder() + .setTripId("t0") + .setStopSequence(2) + .setLocationId("2") + .setStartPickupDropOffWindow(GtfsTime.fromString("06:00:00")) + .setEndPickupDropOffWindow(GtfsTime.fromString("08:00:00")) + .build())); + GtfsStopTimeTableContainer stopTimeTableContainer = + GtfsStopTimeTableContainer.forEntities(stopTimes, noticeContainer); + OverlappingPickupDropOffZoneValidator validator = + new OverlappingPickupDropOffZoneValidator(stopTimeTableContainer, geoJsonFeaturesContainer); + validator.validate(noticeContainer); + + assertThat(noticeContainer.getValidationNotices()).isEmpty(); + } +}