Skip to content

Commit 9badbe8

Browse files
authored
feat: pickup and drop off window validator (#1935)
1 parent 45d7988 commit 9badbe8

File tree

6 files changed

+316
-6
lines changed

6 files changed

+316
-6
lines changed

core/src/main/java/org/mobilitydata/gtfsvalidator/notice/Notice.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@
2525
import com.google.gson.JsonPrimitive;
2626
import com.google.gson.JsonSerializationContext;
2727
import com.google.gson.JsonSerializer;
28+
import java.lang.reflect.Field;
2829
import java.lang.reflect.Type;
30+
import java.util.Arrays;
31+
import java.util.List;
32+
import java.util.stream.Collectors;
2933
import org.apache.commons.lang3.StringUtils;
3034
import org.mobilitydata.gtfsvalidator.type.GtfsColor;
3135
import org.mobilitydata.gtfsvalidator.type.GtfsDate;
@@ -48,6 +52,12 @@ public JsonElement toJsonTree() {
4852
return GSON.toJsonTree(this);
4953
}
5054

55+
public List<String> getAllFields() {
56+
return Arrays.stream(this.getClass().getDeclaredFields())
57+
.map(Field::getName) // Extract the name of each field
58+
.collect(Collectors.toList()); // Collect as a list of strings
59+
}
60+
5161
/**
5262
* Returns a descriptive type-specific name for this notice based on the class simple name.
5363
*

main/src/main/java/org/mobilitydata/gtfsvalidator/report/HtmlReportGenerator.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@
1919
import java.io.FileWriter;
2020
import java.io.IOException;
2121
import java.nio.file.Path;
22+
import java.util.ArrayList;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.stream.Collectors;
2226
import org.mobilitydata.gtfsvalidator.notice.NoticeContainer;
2327
import org.mobilitydata.gtfsvalidator.report.model.FeedMetadata;
28+
import org.mobilitydata.gtfsvalidator.report.model.NoticeView;
2429
import org.mobilitydata.gtfsvalidator.report.model.ReportSummary;
2530
import org.mobilitydata.gtfsvalidator.runner.ValidationRunnerConfig;
2631
import org.mobilitydata.gtfsvalidator.util.VersionInfo;
@@ -56,9 +61,38 @@ public void generateReport(
5661
context.setVariable("config", config);
5762
context.setVariable("date", date);
5863
context.setVariable("is_different_date", is_different_date);
64+
context.setVariable(
65+
"uniqueFieldsByCode",
66+
getUniqueFieldsForCodes(
67+
summary.getNoticesMap().values().stream()
68+
.flatMap(map -> map.entrySet().stream())
69+
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))));
5970

6071
try (FileWriter writer = new FileWriter(reportPath.toFile())) {
6172
templateEngine.process("report.html", context, writer);
6273
}
6374
}
75+
76+
private Map<String, List<String>> getUniqueFieldsForCodes(
77+
Map<String, List<NoticeView>> noticesByCode) {
78+
return noticesByCode.entrySet().stream()
79+
.collect(
80+
Collectors.toMap(
81+
Map.Entry::getKey, // Notice code
82+
entry -> {
83+
// Collect unique fields from all notices for this code
84+
List<String> uniqueFields =
85+
entry.getValue().stream()
86+
.flatMap(notice -> notice.getFields().stream())
87+
.distinct()
88+
.collect(Collectors.toList());
89+
90+
// Start with all fields from the first notice and filter based on unique fields
91+
List<String> filteredFields =
92+
new ArrayList<>(entry.getValue().get(0).getAllFields());
93+
filteredFields.removeIf(field -> !uniqueFields.contains(field));
94+
95+
return filteredFields;
96+
}));
97+
}
6498
}

main/src/main/java/org/mobilitydata/gtfsvalidator/report/model/NoticeView.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,13 @@ public String getDescription() {
9797
public String getCode() {
9898
return notice.getContext().getCode();
9999
}
100+
101+
/**
102+
* Returns a list of all fields in the notice.
103+
*
104+
* @return list of all fields in the notice.
105+
*/
106+
public List<String> getAllFields() {
107+
return notice.getContext().getAllFields();
108+
}
100109
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
* Copyright 2024 MobilityData
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.mobilitydata.gtfsvalidator.validator;
17+
18+
import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR;
19+
20+
import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice;
21+
import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator;
22+
import org.mobilitydata.gtfsvalidator.notice.NoticeContainer;
23+
import org.mobilitydata.gtfsvalidator.notice.ValidationNotice;
24+
import org.mobilitydata.gtfsvalidator.table.GtfsStopTime;
25+
import org.mobilitydata.gtfsvalidator.type.GtfsTime;
26+
27+
/**
28+
* Validates pickup and drop-off windows in the `stop_times.txt` file to ensure compliance with GTFS
29+
* rules.
30+
*
31+
* <p>This validator checks for: - Forbidden use of arrival or departure times when pickup or
32+
* drop-off windows are provided. - Missing start or end pickup/drop-off windows when one of them is
33+
* present. - Invalid pickup/drop-off windows where the end time is not strictly later than the
34+
* start time.
35+
*
36+
* <p>Generated notices include: - {@link ForbiddenArrivalOrDepartureTimeNotice} - {@link
37+
* MissingPickupOrDropOffWindowNotice} - {@link InvalidPickupDropOffWindowNotice}
38+
*/
39+
@GtfsValidator
40+
public class PickupDropOffWindowValidator extends SingleEntityValidator<GtfsStopTime> {
41+
42+
@Override
43+
public void validate(GtfsStopTime stopTime, NoticeContainer noticeContainer) {
44+
// Skip validation if neither start nor end pickup/drop-off window is present
45+
if (!stopTime.hasStartPickupDropOffWindow() && !stopTime.hasEndPickupDropOffWindow()) {
46+
return;
47+
}
48+
49+
// Check for forbidden coexistence of arrival/departure times with pickup/drop-off windows
50+
if (stopTime.hasArrivalTime() || stopTime.hasDepartureTime()) {
51+
noticeContainer.addValidationNotice(
52+
new ForbiddenArrivalOrDepartureTimeNotice(
53+
stopTime.csvRowNumber(),
54+
stopTime.hasArrivalTime() ? stopTime.arrivalTime() : null,
55+
stopTime.hasDepartureTime() ? stopTime.departureTime() : null,
56+
stopTime.hasStartPickupDropOffWindow() ? stopTime.startPickupDropOffWindow() : null,
57+
stopTime.hasEndPickupDropOffWindow() ? stopTime.endPickupDropOffWindow() : null));
58+
}
59+
60+
// Check for missing start or end pickup/drop-off window
61+
if (!stopTime.hasStartPickupDropOffWindow() || !stopTime.hasEndPickupDropOffWindow()) {
62+
noticeContainer.addValidationNotice(
63+
new MissingPickupOrDropOffWindowNotice(
64+
stopTime.csvRowNumber(),
65+
stopTime.hasStartPickupDropOffWindow() ? stopTime.startPickupDropOffWindow() : null,
66+
stopTime.hasEndPickupDropOffWindow() ? stopTime.endPickupDropOffWindow() : null));
67+
return;
68+
}
69+
70+
// Check for invalid pickup/drop-off window (start time must be strictly before end time)
71+
if (stopTime.startPickupDropOffWindow().isAfter(stopTime.endPickupDropOffWindow())
72+
|| stopTime.startPickupDropOffWindow().equals(stopTime.endPickupDropOffWindow())) {
73+
noticeContainer.addValidationNotice(
74+
new InvalidPickupDropOffWindowNotice(
75+
stopTime.csvRowNumber(),
76+
stopTime.startPickupDropOffWindow(),
77+
stopTime.endPickupDropOffWindow()));
78+
}
79+
}
80+
81+
@Override
82+
public boolean shouldCallValidate(ColumnInspector header) {
83+
// No point in validating if there is no start_pickup_drop_off_window column
84+
// and no end_pickup_drop_off_window column
85+
return header.hasColumn(GtfsStopTime.START_PICKUP_DROP_OFF_WINDOW_FIELD_NAME)
86+
|| header.hasColumn(GtfsStopTime.END_PICKUP_DROP_OFF_WINDOW_FIELD_NAME);
87+
}
88+
89+
/**
90+
* The arrival or departure times are provided alongside pickup or drop-off windows in
91+
* `stop_times.txt`.
92+
*
93+
* <p>This violates GTFS specification, as both cannot coexist for a single stop time record.
94+
*/
95+
@GtfsValidationNotice(severity = ERROR)
96+
public static class ForbiddenArrivalOrDepartureTimeNotice extends ValidationNotice {
97+
98+
/** The row of the faulty record. */
99+
private final int csvRowNumber;
100+
101+
/** The arrival time of the faulty record. */
102+
private final GtfsTime arrivalTime;
103+
104+
/** The departure time of the faulty record. */
105+
private final GtfsTime departureTime;
106+
107+
/** The start pickup drop off window of the faulty record. */
108+
private final GtfsTime startPickupDropOffWindow;
109+
110+
/** The end pickup drop off window of the faulty record. */
111+
private final GtfsTime endPickupDropOffWindow;
112+
113+
public ForbiddenArrivalOrDepartureTimeNotice(
114+
int csvRowNumber,
115+
GtfsTime arrivalTime,
116+
GtfsTime departureTime,
117+
GtfsTime startPickupDropOffWindow,
118+
GtfsTime endPickupDropOffWindow) {
119+
this.csvRowNumber = csvRowNumber;
120+
this.arrivalTime = arrivalTime;
121+
this.departureTime = departureTime;
122+
this.startPickupDropOffWindow = startPickupDropOffWindow;
123+
this.endPickupDropOffWindow = endPickupDropOffWindow;
124+
}
125+
}
126+
127+
/**
128+
* Either the start or end pickup/drop-off window is missing in `stop_times.txt`.
129+
*
130+
* <p>GTFS specification requires both the start and end pickup/drop-off windows to be provided
131+
* together, if used.
132+
*/
133+
@GtfsValidationNotice(severity = ERROR)
134+
public static class MissingPickupOrDropOffWindowNotice extends ValidationNotice {
135+
/** The row of the faulty record. */
136+
private final int csvRowNumber;
137+
138+
/** The start pickup drop off window of the faulty record. */
139+
private final GtfsTime startPickupDropOffWindow;
140+
141+
/** The end pickup drop off window of the faulty record. */
142+
private final GtfsTime endPickupDropOffWindow;
143+
144+
public MissingPickupOrDropOffWindowNotice(
145+
int csvRowNumber, GtfsTime startPickupDropOffWindow, GtfsTime endPickupDropOffWindow) {
146+
this.csvRowNumber = csvRowNumber;
147+
this.startPickupDropOffWindow = startPickupDropOffWindow;
148+
this.endPickupDropOffWindow = endPickupDropOffWindow;
149+
}
150+
}
151+
152+
/**
153+
* The pickup/drop-off window in `stop_times.txt` is invalid.
154+
*
155+
* <p>The `end_pickup_drop_off_window` must be strictly later than the
156+
* `start_pickup_drop_off_window`.
157+
*/
158+
@GtfsValidationNotice(severity = ERROR)
159+
public static class InvalidPickupDropOffWindowNotice extends ValidationNotice {
160+
/** The row of the faulty record. */
161+
private final int csvRowNumber;
162+
163+
/** The start pickup drop off window of the faulty record. */
164+
private final GtfsTime startPickupDropOffWindow;
165+
166+
/** The end pickup drop off window of the faulty record. */
167+
private final GtfsTime endPickupDropOffWindow;
168+
169+
public InvalidPickupDropOffWindowNotice(
170+
int csvRowNumber, GtfsTime startPickupDropOffWindow, GtfsTime endPickupDropOffWindow) {
171+
this.csvRowNumber = csvRowNumber;
172+
this.startPickupDropOffWindow = startPickupDropOffWindow;
173+
this.endPickupDropOffWindow = endPickupDropOffWindow;
174+
}
175+
}
176+
}

main/src/main/resources/report.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,9 @@ <h3 th:text="${noticesByCode.key}" />
368368
<table>
369369
<thead>
370370
<tr>
371-
<th:block th:each="field: ${noticesByCode.value[0].fields}">
371+
<th:block th:each="field: ${uniqueFieldsByCode[noticesByCode.key]}">
372372
<th>
373-
<span th:text="${field}"></span>
373+
<span th:text="${field}"></span>
374374
<a href="#" class="tooltip" onclick="event.preventDefault();"><span>(?)</span>
375375
<span class="tooltiptext" th:text="${noticesByCode.value[0].getCommentForField(field)}"></span>
376376
</a>
@@ -379,10 +379,10 @@ <h3 th:text="${noticesByCode.key}" />
379379
</tr>
380380
</thead>
381381
<tbody>
382-
<tr th:each="notice, iStat : ${noticesByCode.value}" th:if="${iStat.count <= 50}">
383-
<th:block th:each="field: ${notice.fields}">
384-
<td th:text="${notice.getValueForField(field)}" />
385-
</th:block>
382+
<tr th:each="notice: ${noticesByCode.value}">
383+
<th:block th:each="field: ${uniqueFieldsByCode[noticesByCode.key]}">
384+
<td th:text="${notice.getFields().contains(field) ? notice.getValueForField(field) : 'N/A'}" />
385+
</th:block>
386386
</tr>
387387
</tbody>
388388
</table>
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package org.mobilitydata.gtfsvalidator.validator;
2+
3+
import static com.google.common.truth.Truth.assertThat;
4+
5+
import org.junit.Test;
6+
import org.junit.runner.RunWith;
7+
import org.junit.runners.JUnit4;
8+
import org.mobilitydata.gtfsvalidator.notice.NoticeContainer;
9+
import org.mobilitydata.gtfsvalidator.table.GtfsStopTime;
10+
import org.mobilitydata.gtfsvalidator.type.GtfsTime;
11+
12+
@RunWith(JUnit4.class)
13+
public class PickupDropOffWindowValidatorTest {
14+
@Test
15+
public void shouldGenerateForbiddenArrivalOrDepartureTimeNotice() {
16+
NoticeContainer noticeContainer = new NoticeContainer();
17+
PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator();
18+
19+
GtfsStopTime stopTime =
20+
new GtfsStopTime.Builder()
21+
.setCsvRowNumber(1)
22+
.setArrivalTime(GtfsTime.fromString("00:00:00"))
23+
.setDepartureTime(GtfsTime.fromString("00:00:01"))
24+
.setStartPickupDropOffWindow(GtfsTime.fromString("00:00:02"))
25+
.setEndPickupDropOffWindow(GtfsTime.fromString("00:00:03"))
26+
.build();
27+
validator.validate(stopTime, noticeContainer);
28+
assertThat(noticeContainer.getValidationNotices()).hasSize(1);
29+
assertThat(noticeContainer.getValidationNotices().stream().findFirst().get())
30+
.isInstanceOf(PickupDropOffWindowValidator.ForbiddenArrivalOrDepartureTimeNotice.class);
31+
}
32+
33+
@Test
34+
public void shouldGenerateMissingPickupOrDropOffWindowNotice_missingStart() {
35+
NoticeContainer noticeContainer = new NoticeContainer();
36+
PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator();
37+
38+
GtfsStopTime stopTime =
39+
new GtfsStopTime.Builder()
40+
.setCsvRowNumber(1)
41+
.setEndPickupDropOffWindow(GtfsTime.fromString("00:00:03"))
42+
.build();
43+
validator.validate(stopTime, noticeContainer);
44+
assertThat(noticeContainer.getValidationNotices()).hasSize(1);
45+
assertThat(noticeContainer.getValidationNotices().stream().findFirst().get())
46+
.isInstanceOf(PickupDropOffWindowValidator.MissingPickupOrDropOffWindowNotice.class);
47+
}
48+
49+
@Test
50+
public void shouldGenerateMissingPickupOrDropOffWindowNotice_missingEnd() {
51+
NoticeContainer noticeContainer = new NoticeContainer();
52+
PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator();
53+
54+
GtfsStopTime stopTime =
55+
new GtfsStopTime.Builder()
56+
.setCsvRowNumber(1)
57+
.setStartPickupDropOffWindow(GtfsTime.fromString("00:00:03"))
58+
.build();
59+
validator.validate(stopTime, noticeContainer);
60+
assertThat(noticeContainer.getValidationNotices()).hasSize(1);
61+
assertThat(noticeContainer.getValidationNotices().stream().findFirst().get())
62+
.isInstanceOf(PickupDropOffWindowValidator.MissingPickupOrDropOffWindowNotice.class);
63+
}
64+
65+
@Test
66+
public void shouldGenerateInvalidPickupDropOffWindowNotice() {
67+
NoticeContainer noticeContainer = new NoticeContainer();
68+
PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator();
69+
70+
GtfsStopTime stopTime =
71+
new GtfsStopTime.Builder()
72+
.setCsvRowNumber(1)
73+
.setStartPickupDropOffWindow(GtfsTime.fromString("00:00:03"))
74+
.setEndPickupDropOffWindow(GtfsTime.fromString("00:00:02"))
75+
.build();
76+
validator.validate(stopTime, noticeContainer);
77+
assertThat(noticeContainer.getValidationNotices()).hasSize(1);
78+
assertThat(noticeContainer.getValidationNotices().stream().findFirst().get())
79+
.isInstanceOf(PickupDropOffWindowValidator.InvalidPickupDropOffWindowNotice.class);
80+
}
81+
}

0 commit comments

Comments
 (0)