From d450f37bc914fde035cbf2645d5931c4d567917c Mon Sep 17 00:00:00 2001 From: vssinghh Date: Tue, 4 Aug 2026 22:10:28 -0700 Subject: [PATCH 1/4] feat: recommend stair_count for stairs pathways The GTFS reference states that pathways.stair_count is recommended for stairs (pathway_mode=2). Nothing checked this, so feeds could omit the step count on stairs without any signal. Adds PathwayStairCountValidator, which emits the existing MissingRecommendedFieldNotice when a pathway has pathway_mode=2 and no stair_count. This follows the same shape as FareMediaNameValidator and RouteAgencyIdValidator, which raise the generic recommended-field notice under a conditional rather than defining a new notice type. Closes #1788 --- .../validator/PathwayStairCountValidator.java | 29 +++++++++ .../PathwayStairCountValidatorTest.java | 63 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidator.java create mode 100644 main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidatorTest.java diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidator.java new file mode 100644 index 0000000000..3852a694ea --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidator.java @@ -0,0 +1,29 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator; +import org.mobilitydata.gtfsvalidator.notice.MissingRecommendedFieldNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsPathway; +import org.mobilitydata.gtfsvalidator.table.GtfsPathwayMode; + +/** + * Validates that {@code stair_count} is provided for pathways that are stairs. + * + *

The GTFS specification recommends {@code pathways.stair_count} for stairs ({@code + * pathway_mode=2}). Without it, a consumer cannot tell how many steps a path involves, which is + * information riders with limited mobility rely on when choosing a route through a station. + * + *

Generated notice: {@link MissingRecommendedFieldNotice}. + */ +@GtfsValidator +public class PathwayStairCountValidator extends SingleEntityValidator { + + @Override + public void validate(GtfsPathway entity, NoticeContainer noticeContainer) { + if (entity.pathwayMode() == GtfsPathwayMode.STAIRS && !entity.hasStairCount()) { + noticeContainer.addValidationNotice( + new MissingRecommendedFieldNotice( + GtfsPathway.FILENAME, entity.csvRowNumber(), GtfsPathway.STAIR_COUNT_FIELD_NAME)); + } + } +} diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidatorTest.java new file mode 100644 index 0000000000..381feaacb3 --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidatorTest.java @@ -0,0 +1,63 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mobilitydata.gtfsvalidator.notice.MissingRecommendedFieldNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsPathway; +import org.mobilitydata.gtfsvalidator.table.GtfsPathwayMode; + +@RunWith(JUnit4.class) +public class PathwayStairCountValidatorTest { + + @Test + public void stairsWithoutStairCount_yieldsNotice() { + assertThat(validationNoticesFor(pathwayBuilder(GtfsPathwayMode.STAIRS).build())) + .containsExactly(new MissingRecommendedFieldNotice("pathways.txt", 2, "stair_count")); + } + + @Test + public void stairsWithStairCount_yieldsNoNotice() { + assertThat( + validationNoticesFor(pathwayBuilder(GtfsPathwayMode.STAIRS).setStairCount(5).build())) + .isEmpty(); + } + + @Test + public void stairsWithNegativeStairCount_yieldsNoNotice() { + // stair_count is signed: a negative value describes a descending path, so it is still defined. + assertThat( + validationNoticesFor(pathwayBuilder(GtfsPathwayMode.STAIRS).setStairCount(-5).build())) + .isEmpty(); + } + + @Test + public void nonStairsWithoutStairCount_yieldsNoNotice() { + for (GtfsPathwayMode mode : GtfsPathwayMode.values()) { + if (mode == GtfsPathwayMode.STAIRS) { + continue; + } + assertThat(validationNoticesFor(pathwayBuilder(mode).build())).isEmpty(); + } + } + + private static GtfsPathway.Builder pathwayBuilder(GtfsPathwayMode pathwayMode) { + return new GtfsPathway.Builder() + .setCsvRowNumber(2) + .setPathwayId("pathway1") + .setFromStopId("stop1") + .setToStopId("stop2") + .setPathwayMode(pathwayMode); + } + + private static List validationNoticesFor(GtfsPathway entity) { + NoticeContainer noticeContainer = new NoticeContainer(); + new PathwayStairCountValidator().validate(entity, noticeContainer); + return noticeContainer.getValidationNotices(); + } +} From ad6f5d58c12b161608b1a3445cafcf3c81dc37f9 Mon Sep 17 00:00:00 2001 From: vssinghh Date: Wed, 5 Aug 2026 12:43:36 -0700 Subject: [PATCH 2/4] feat: validate traversal_time and max_slope against pathway_mode Extends the stair_count rule to the other two pathway fields whose expectations depend on pathway_mode, as requested in review: - traversal_time is recommended for pathway_mode 3, 4 and 5, reported with the existing missing_recommended_field notice - max_slope should only be used with pathway_mode 1 and 3, reported with a new irrelevant_max_slope_set_for_pathway_mode warning A max_slope of 0 is not reported, since the spec defines both an empty value and 0 as "no slope". Renames PathwayStairCountValidator to PathwayModeFieldsValidator. Closes #1789 --- .../validator/PathwayModeFieldsValidator.java | 104 ++++++++++++++ .../validator/PathwayStairCountValidator.java | 29 ---- .../validator/NoticeFieldsTest.java | 1 + .../PathwayModeFieldsValidatorTest.java | 131 ++++++++++++++++++ .../PathwayStairCountValidatorTest.java | 63 --------- 5 files changed, 236 insertions(+), 92 deletions(-) create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidator.java delete mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidator.java create mode 100644 main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidatorTest.java delete mode 100644 main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidatorTest.java diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidator.java new file mode 100644 index 0000000000..e92feedd27 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidator.java @@ -0,0 +1,104 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.WARNING; + +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.FileRefs; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator; +import org.mobilitydata.gtfsvalidator.notice.MissingRecommendedFieldNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsPathway; +import org.mobilitydata.gtfsvalidator.table.GtfsPathwayMode; +import org.mobilitydata.gtfsvalidator.table.GtfsPathwaySchema; + +/** + * Validates the pathway fields whose expectations depend on {@code pathway_mode}. + * + *

+ * + *

Generated notices: {@link MissingRecommendedFieldNotice}, {@link + * IrrelevantMaxSlopeSetForPathwayModeNotice}. + */ +@GtfsValidator +public class PathwayModeFieldsValidator extends SingleEntityValidator { + + @Override + public void validate(GtfsPathway entity, NoticeContainer noticeContainer) { + GtfsPathwayMode pathwayMode = entity.pathwayMode(); + + if (pathwayMode == GtfsPathwayMode.STAIRS && !entity.hasStairCount()) { + noticeContainer.addValidationNotice( + new MissingRecommendedFieldNotice( + GtfsPathway.FILENAME, entity.csvRowNumber(), GtfsPathway.STAIR_COUNT_FIELD_NAME)); + } + + if (recommendsTraversalTime(pathwayMode) && !entity.hasTraversalTime()) { + noticeContainer.addValidationNotice( + new MissingRecommendedFieldNotice( + GtfsPathway.FILENAME, entity.csvRowNumber(), GtfsPathway.TRAVERSAL_TIME_FIELD_NAME)); + } + + // The spec defines both an empty max_slope and a max_slope of 0 as "no slope", so a zero value + // carries no more meaning than leaving the field out and is not worth reporting. + if (!allowsMaxSlope(pathwayMode) && entity.hasMaxSlope() && entity.maxSlope() != 0) { + noticeContainer.addValidationNotice(new IrrelevantMaxSlopeSetForPathwayModeNotice(entity)); + } + } + + private static boolean recommendsTraversalTime(GtfsPathwayMode pathwayMode) { + switch (pathwayMode) { + case MOVING_SIDEWALK: + case ESCALATOR: + case ELEVATOR: + return true; + default: + return false; + } + } + + private static boolean allowsMaxSlope(GtfsPathwayMode pathwayMode) { + switch (pathwayMode) { + case WALKWAY: + case MOVING_SIDEWALK: + return true; + default: + return false; + } + } + + /** + * A pathway that is not a walkway or a moving sidewalk defines `max_slope`. + * + *

The GTFS specification states that `max_slope` should only be used with walkways + * (`pathway_mode=1`) and moving sidewalks (`pathway_mode=3`). A `max_slope` of `0` means no slope + * and is not reported. + */ + @GtfsValidationNotice(severity = WARNING, files = @FileRefs({GtfsPathwaySchema.class})) + static class IrrelevantMaxSlopeSetForPathwayModeNotice extends ValidationNotice { + /** The row number of the faulty record. */ + private final int csvRowNumber; + + /** The `pathway_id` of the faulty record. */ + private final String pathwayId; + + /** The `pathway_mode` of the faulty record. */ + private final int pathwayMode; + + /** The `max_slope` defined on the faulty record. */ + private final double maxSlope; + + IrrelevantMaxSlopeSetForPathwayModeNotice(GtfsPathway pathway) { + this.csvRowNumber = pathway.csvRowNumber(); + this.pathwayId = pathway.pathwayId(); + this.pathwayMode = pathway.pathwayMode().getNumber(); + this.maxSlope = pathway.maxSlope(); + } + } +} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidator.java deleted file mode 100644 index 3852a694ea..0000000000 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidator.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.mobilitydata.gtfsvalidator.validator; - -import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator; -import org.mobilitydata.gtfsvalidator.notice.MissingRecommendedFieldNotice; -import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; -import org.mobilitydata.gtfsvalidator.table.GtfsPathway; -import org.mobilitydata.gtfsvalidator.table.GtfsPathwayMode; - -/** - * Validates that {@code stair_count} is provided for pathways that are stairs. - * - *

The GTFS specification recommends {@code pathways.stair_count} for stairs ({@code - * pathway_mode=2}). Without it, a consumer cannot tell how many steps a path involves, which is - * information riders with limited mobility rely on when choosing a route through a station. - * - *

Generated notice: {@link MissingRecommendedFieldNotice}. - */ -@GtfsValidator -public class PathwayStairCountValidator extends SingleEntityValidator { - - @Override - public void validate(GtfsPathway entity, NoticeContainer noticeContainer) { - if (entity.pathwayMode() == GtfsPathwayMode.STAIRS && !entity.hasStairCount()) { - noticeContainer.addValidationNotice( - new MissingRecommendedFieldNotice( - GtfsPathway.FILENAME, entity.csvRowNumber(), GtfsPathway.STAIR_COUNT_FIELD_NAME)); - } - } -} 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 7a6840a1f0..5a8315b4af 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java @@ -144,6 +144,7 @@ public void testNoticeClassFieldNames() { "match2", "matchCount", "maxShapeDistanceTraveled", + "maxSlope", "maxTripDistanceTraveled", "message", "minServiceStartDate", diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidatorTest.java new file mode 100644 index 0000000000..aa3934459e --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidatorTest.java @@ -0,0 +1,131 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mobilitydata.gtfsvalidator.notice.MissingRecommendedFieldNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsPathway; +import org.mobilitydata.gtfsvalidator.table.GtfsPathwayMode; + +@RunWith(JUnit4.class) +public class PathwayModeFieldsValidatorTest { + + // stair_count is recommended for stairs (pathway_mode=2). + + @Test + public void stairsWithoutStairCount_yieldsNotice() { + assertThat(validationNoticesFor(pathway(GtfsPathwayMode.STAIRS).build())) + .containsExactly(new MissingRecommendedFieldNotice("pathways.txt", 2, "stair_count")); + } + + @Test + public void stairsWithStairCount_yieldsNoNotice() { + assertThat(validationNoticesFor(pathway(GtfsPathwayMode.STAIRS).setStairCount(5).build())) + .isEmpty(); + } + + @Test + public void stairsWithNegativeStairCount_yieldsNoNotice() { + // The spec says a negative stair_count describes walking down, so it is still defined. + assertThat(validationNoticesFor(pathway(GtfsPathwayMode.STAIRS).setStairCount(-5).build())) + .isEmpty(); + } + + @Test + public void nonStairsWithoutStairCount_yieldsNoStairCountNotice() { + for (GtfsPathwayMode mode : GtfsPathwayMode.values()) { + if (mode == GtfsPathwayMode.STAIRS) { + continue; + } + assertThat(validationNoticesFor(pathway(mode).setTraversalTime(30).build())) + .doesNotContain(new MissingRecommendedFieldNotice("pathways.txt", 2, "stair_count")); + } + } + + // traversal_time is recommended for moving sidewalks, escalators and elevators. + + @Test + public void mechanicalPathwaysWithoutTraversalTime_yieldNotice() { + for (GtfsPathwayMode mode : + List.of( + GtfsPathwayMode.MOVING_SIDEWALK, GtfsPathwayMode.ESCALATOR, GtfsPathwayMode.ELEVATOR)) { + assertThat(validationNoticesFor(pathway(mode).build())) + .containsExactly(new MissingRecommendedFieldNotice("pathways.txt", 2, "traversal_time")); + } + } + + @Test + public void mechanicalPathwaysWithTraversalTime_yieldNoNotice() { + for (GtfsPathwayMode mode : + List.of( + GtfsPathwayMode.MOVING_SIDEWALK, GtfsPathwayMode.ESCALATOR, GtfsPathwayMode.ELEVATOR)) { + assertThat(validationNoticesFor(pathway(mode).setTraversalTime(45).build())).isEmpty(); + } + } + + @Test + public void walkwayWithoutTraversalTime_yieldsNoNotice() { + assertThat(validationNoticesFor(pathway(GtfsPathwayMode.WALKWAY).build())).isEmpty(); + } + + // max_slope should only be used with walkways and moving sidewalks. + + @Test + public void maxSlopeOnDisallowedMode_yieldsNotice() { + GtfsPathway entity = + pathway(GtfsPathwayMode.ELEVATOR).setMaxSlope(0.083).setTraversalTime(30).build(); + assertThat(validationNoticesFor(entity)) + .containsExactly( + new PathwayModeFieldsValidator.IrrelevantMaxSlopeSetForPathwayModeNotice(entity)); + } + + @Test + public void maxSlopeOnWalkwayOrMovingSidewalk_yieldsNoNotice() { + assertThat(validationNoticesFor(pathway(GtfsPathwayMode.WALKWAY).setMaxSlope(0.083).build())) + .isEmpty(); + assertThat( + validationNoticesFor( + pathway(GtfsPathwayMode.MOVING_SIDEWALK) + .setMaxSlope(0.083) + .setTraversalTime(30) + .build())) + .isEmpty(); + } + + @Test + public void zeroMaxSlopeOnDisallowedMode_yieldsNoNotice() { + // The spec treats an empty max_slope and a max_slope of 0 alike, both meaning no slope. + GtfsPathway entity = + pathway(GtfsPathwayMode.ELEVATOR).setMaxSlope(0.0).setTraversalTime(30).build(); + assertThat(validationNoticesFor(entity)).isEmpty(); + } + + @Test + public void negativeMaxSlopeOnDisallowedMode_yieldsNotice() { + // A negative slope describes a downward pathway, so it is a real value. + GtfsPathway entity = pathway(GtfsPathwayMode.EXIT_GATE).setMaxSlope(-0.05).build(); + assertThat(validationNoticesFor(entity)) + .containsExactly( + new PathwayModeFieldsValidator.IrrelevantMaxSlopeSetForPathwayModeNotice(entity)); + } + + private static GtfsPathway.Builder pathway(GtfsPathwayMode pathwayMode) { + return new GtfsPathway.Builder() + .setCsvRowNumber(2) + .setPathwayId("pathway1") + .setFromStopId("stop1") + .setToStopId("stop2") + .setPathwayMode(pathwayMode); + } + + private static List validationNoticesFor(GtfsPathway entity) { + NoticeContainer noticeContainer = new NoticeContainer(); + new PathwayModeFieldsValidator().validate(entity, noticeContainer); + return noticeContainer.getValidationNotices(); + } +} diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidatorTest.java deleted file mode 100644 index 381feaacb3..0000000000 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PathwayStairCountValidatorTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.mobilitydata.gtfsvalidator.validator; - -import static com.google.common.truth.Truth.assertThat; - -import java.util.List; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; -import org.mobilitydata.gtfsvalidator.notice.MissingRecommendedFieldNotice; -import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; -import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; -import org.mobilitydata.gtfsvalidator.table.GtfsPathway; -import org.mobilitydata.gtfsvalidator.table.GtfsPathwayMode; - -@RunWith(JUnit4.class) -public class PathwayStairCountValidatorTest { - - @Test - public void stairsWithoutStairCount_yieldsNotice() { - assertThat(validationNoticesFor(pathwayBuilder(GtfsPathwayMode.STAIRS).build())) - .containsExactly(new MissingRecommendedFieldNotice("pathways.txt", 2, "stair_count")); - } - - @Test - public void stairsWithStairCount_yieldsNoNotice() { - assertThat( - validationNoticesFor(pathwayBuilder(GtfsPathwayMode.STAIRS).setStairCount(5).build())) - .isEmpty(); - } - - @Test - public void stairsWithNegativeStairCount_yieldsNoNotice() { - // stair_count is signed: a negative value describes a descending path, so it is still defined. - assertThat( - validationNoticesFor(pathwayBuilder(GtfsPathwayMode.STAIRS).setStairCount(-5).build())) - .isEmpty(); - } - - @Test - public void nonStairsWithoutStairCount_yieldsNoNotice() { - for (GtfsPathwayMode mode : GtfsPathwayMode.values()) { - if (mode == GtfsPathwayMode.STAIRS) { - continue; - } - assertThat(validationNoticesFor(pathwayBuilder(mode).build())).isEmpty(); - } - } - - private static GtfsPathway.Builder pathwayBuilder(GtfsPathwayMode pathwayMode) { - return new GtfsPathway.Builder() - .setCsvRowNumber(2) - .setPathwayId("pathway1") - .setFromStopId("stop1") - .setToStopId("stop2") - .setPathwayMode(pathwayMode); - } - - private static List validationNoticesFor(GtfsPathway entity) { - NoticeContainer noticeContainer = new NoticeContainer(); - new PathwayStairCountValidator().validate(entity, noticeContainer); - return noticeContainer.getValidationNotices(); - } -} From b2000b734b3d3d1d396513cd7661fd2b068c55c4 Mon Sep 17 00:00:00 2001 From: vssinghh Date: Thu, 6 Aug 2026 11:07:45 -0700 Subject: [PATCH 3/4] feat: recommend length for walkways, fare gates and exit gates Completes the set of pathway fields whose expectations depend on pathway_mode. The spec recommends length for walkways (pathway_mode=1), fare gates (pathway_mode=6) and exit gates (pathway_mode=7), reported with the existing missing_recommended_field notice. Unlike max_slope, the spec gives no special meaning to a length of 0, so a zero value counts as defined and is not reported. Closes #1787 --- .../validator/PathwayModeFieldsValidator.java | 19 ++++++++ .../PathwayModeFieldsValidatorTest.java | 43 +++++++++++++++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidator.java index e92feedd27..6a3c8f6ed1 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidator.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PathwayModeFieldsValidator.java @@ -16,6 +16,8 @@ * Validates the pathway fields whose expectations depend on {@code pathway_mode}. * *