Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: pickup and drop off window validator #1935

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import java.lang.reflect.Field;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.mobilitydata.gtfsvalidator.type.GtfsColor;
import org.mobilitydata.gtfsvalidator.type.GtfsDate;
Expand All @@ -48,6 +52,12 @@ public JsonElement toJsonTree() {
return GSON.toJsonTree(this);
}

public List<String> getAllFields() {
return Arrays.stream(this.getClass().getDeclaredFields())
.map(Field::getName) // Extract the name of each field
.collect(Collectors.toList()); // Collect as a list of strings
}

/**
* Returns a descriptive type-specific name for this notice based on the class simple name.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.mobilitydata.gtfsvalidator.notice.NoticeContainer;
import org.mobilitydata.gtfsvalidator.report.model.FeedMetadata;
import org.mobilitydata.gtfsvalidator.report.model.NoticeView;
import org.mobilitydata.gtfsvalidator.report.model.ReportSummary;
import org.mobilitydata.gtfsvalidator.runner.ValidationRunnerConfig;
import org.mobilitydata.gtfsvalidator.util.VersionInfo;
Expand Down Expand Up @@ -56,9 +61,38 @@ public void generateReport(
context.setVariable("config", config);
context.setVariable("date", date);
context.setVariable("is_different_date", is_different_date);
context.setVariable(
"uniqueFieldsByCode",
getUniqueFieldsForCodes(
summary.getNoticesMap().values().stream()
.flatMap(map -> map.entrySet().stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))));

try (FileWriter writer = new FileWriter(reportPath.toFile())) {
templateEngine.process("report.html", context, writer);
}
}

private Map<String, List<String>> getUniqueFieldsForCodes(
Map<String, List<NoticeView>> noticesByCode) {
return noticesByCode.entrySet().stream()
.collect(
Collectors.toMap(
Map.Entry::getKey, // Notice code
entry -> {
// Collect unique fields from all notices for this code
List<String> uniqueFields =
entry.getValue().stream()
.flatMap(notice -> notice.getFields().stream())
.distinct()
.collect(Collectors.toList());

// Start with all fields from the first notice and filter based on unique fields
List<String> filteredFields =
new ArrayList<>(entry.getValue().get(0).getAllFields());
filteredFields.removeIf(field -> !uniqueFields.contains(field));

return filteredFields;
}));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,13 @@ public String getDescription() {
public String getCode() {
return notice.getContext().getCode();
}

/**
* Returns a list of all fields in the notice.
*
* @return list of all fields in the notice.
*/
public List<String> getAllFields() {
return notice.getContext().getAllFields();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Copyright 2024 MobilityData
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.mobilitydata.gtfsvalidator.validator;

import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR;

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.GtfsStopTime;
import org.mobilitydata.gtfsvalidator.type.GtfsTime;

/**
* Validates pickup and drop-off windows in the `stop_times.txt` file to ensure compliance with GTFS
* rules.
*
* <p>This validator checks for: - Forbidden use of arrival or departure times when pickup or
* drop-off windows are provided. - Missing start or end pickup/drop-off windows when one of them is
* present. - Invalid pickup/drop-off windows where the end time is not strictly later than the
* start time.
*
* <p>Generated notices include: - {@link ForbiddenArrivalOrDepartureTimeNotice} - {@link
* MissingPickupOrDropOffWindowNotice} - {@link InvalidPickupDropOffWindowNotice}
*/
@GtfsValidator
public class PickupDropOffWindowValidator extends SingleEntityValidator<GtfsStopTime> {

@Override
public void validate(GtfsStopTime stopTime, NoticeContainer noticeContainer) {
// Skip validation if neither start nor end pickup/drop-off window is present
if (!stopTime.hasStartPickupDropOffWindow() && !stopTime.hasEndPickupDropOffWindow()) {
return;
}

// Check for forbidden coexistence of arrival/departure times with pickup/drop-off windows
if (stopTime.hasArrivalTime() || stopTime.hasDepartureTime()) {
noticeContainer.addValidationNotice(
new ForbiddenArrivalOrDepartureTimeNotice(
stopTime.csvRowNumber(),
stopTime.hasArrivalTime() ? stopTime.arrivalTime() : null,
stopTime.hasDepartureTime() ? stopTime.departureTime() : null,
stopTime.hasStartPickupDropOffWindow() ? stopTime.startPickupDropOffWindow() : null,
stopTime.hasEndPickupDropOffWindow() ? stopTime.endPickupDropOffWindow() : null));
}

// Check for missing start or end pickup/drop-off window
if (!stopTime.hasStartPickupDropOffWindow() || !stopTime.hasEndPickupDropOffWindow()) {
noticeContainer.addValidationNotice(
new MissingPickupOrDropOffWindowNotice(
stopTime.csvRowNumber(),
stopTime.hasStartPickupDropOffWindow() ? stopTime.startPickupDropOffWindow() : null,
stopTime.hasEndPickupDropOffWindow() ? stopTime.endPickupDropOffWindow() : null));
return;
}

// Check for invalid pickup/drop-off window (start time must be strictly before end time)
if (stopTime.startPickupDropOffWindow().isAfter(stopTime.endPickupDropOffWindow())
|| stopTime.startPickupDropOffWindow().equals(stopTime.endPickupDropOffWindow())) {
noticeContainer.addValidationNotice(
new InvalidPickupDropOffWindowNotice(
stopTime.csvRowNumber(),
stopTime.startPickupDropOffWindow(),
stopTime.endPickupDropOffWindow()));
}
}

@Override
public boolean shouldCallValidate(ColumnInspector header) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

// No point in validating if there is no start_pickup_drop_off_window column
// and no end_pickup_drop_off_window column
return header.hasColumn(GtfsStopTime.START_PICKUP_DROP_OFF_WINDOW_FIELD_NAME)
|| header.hasColumn(GtfsStopTime.END_PICKUP_DROP_OFF_WINDOW_FIELD_NAME);
}

/**
* The arrival or departure times are provided alongside pickup or drop-off windows in
* `stop_times.txt`.
*
* <p>This violates GTFS specification, as both cannot coexist for a single stop time record.
*/
@GtfsValidationNotice(severity = ERROR)
public static class ForbiddenArrivalOrDepartureTimeNotice extends ValidationNotice {

/** The row of the faulty record. */
private final int csvRowNumber;

/** The arrival time of the faulty record. */
private final GtfsTime arrivalTime;

/** The departure time of the faulty record. */
private final GtfsTime departureTime;

/** The start pickup drop off window of the faulty record. */
private final GtfsTime startPickupDropOffWindow;

/** The end pickup drop off window of the faulty record. */
private final GtfsTime endPickupDropOffWindow;

public ForbiddenArrivalOrDepartureTimeNotice(
int csvRowNumber,
GtfsTime arrivalTime,
GtfsTime departureTime,
GtfsTime startPickupDropOffWindow,
GtfsTime endPickupDropOffWindow) {
this.csvRowNumber = csvRowNumber;
this.arrivalTime = arrivalTime;
this.departureTime = departureTime;
this.startPickupDropOffWindow = startPickupDropOffWindow;
this.endPickupDropOffWindow = endPickupDropOffWindow;
}
}

/**
* Either the start or end pickup/drop-off window is missing in `stop_times.txt`.
*
* <p>GTFS specification requires both the start and end pickup/drop-off windows to be provided
* together, if used.
*/
@GtfsValidationNotice(severity = ERROR)
public static class MissingPickupOrDropOffWindowNotice extends ValidationNotice {
/** The row of the faulty record. */
private final int csvRowNumber;

/** The start pickup drop off window of the faulty record. */
private final GtfsTime startPickupDropOffWindow;

/** The end pickup drop off window of the faulty record. */
private final GtfsTime endPickupDropOffWindow;

public MissingPickupOrDropOffWindowNotice(
int csvRowNumber, GtfsTime startPickupDropOffWindow, GtfsTime endPickupDropOffWindow) {
this.csvRowNumber = csvRowNumber;
this.startPickupDropOffWindow = startPickupDropOffWindow;
this.endPickupDropOffWindow = endPickupDropOffWindow;
}
}

/**
* The pickup/drop-off window in `stop_times.txt` is invalid.
*
* <p>The `end_pickup_drop_off_window` must be strictly later than the
* `start_pickup_drop_off_window`.
*/
@GtfsValidationNotice(severity = ERROR)
public static class InvalidPickupDropOffWindowNotice extends ValidationNotice {
/** The row of the faulty record. */
private final int csvRowNumber;

/** The start pickup drop off window of the faulty record. */
private final GtfsTime startPickupDropOffWindow;

/** The end pickup drop off window of the faulty record. */
private final GtfsTime endPickupDropOffWindow;

public InvalidPickupDropOffWindowNotice(
int csvRowNumber, GtfsTime startPickupDropOffWindow, GtfsTime endPickupDropOffWindow) {
this.csvRowNumber = csvRowNumber;
this.startPickupDropOffWindow = startPickupDropOffWindow;
this.endPickupDropOffWindow = endPickupDropOffWindow;
}
}
}
12 changes: 6 additions & 6 deletions main/src/main/resources/report.html
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,9 @@ <h3 th:text="${noticesByCode.key}" />
<table>
<thead>
<tr>
<th:block th:each="field: ${noticesByCode.value[0].fields}">
<th:block th:each="field: ${uniqueFieldsByCode[noticesByCode.key]}">
<th>
<span th:text="${field}"></span>
<span th:text="${field}"></span>
<a href="#" class="tooltip" onclick="event.preventDefault();"><span>(?)</span>
<span class="tooltiptext" th:text="${noticesByCode.value[0].getCommentForField(field)}"></span>
</a>
Expand All @@ -379,10 +379,10 @@ <h3 th:text="${noticesByCode.key}" />
</tr>
</thead>
<tbody>
<tr th:each="notice, iStat : ${noticesByCode.value}" th:if="${iStat.count <= 50}">
<th:block th:each="field: ${notice.fields}">
<td th:text="${notice.getValueForField(field)}" />
</th:block>
<tr th:each="notice: ${noticesByCode.value}">
<th:block th:each="field: ${uniqueFieldsByCode[noticesByCode.key]}">
<td th:text="${notice.getFields().contains(field) ? notice.getValueForField(field) : 'N/A'}" />
</th:block>
</tr>
</tbody>
</table>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public void testNoticeClassFieldNames() {
"departureTime1",
"distanceKm",
"endFieldName",
"endPickupDropOffWindow",
"endValue",
"entityCount",
"entityId",
Expand Down Expand Up @@ -182,6 +183,7 @@ public void testNoticeClassFieldNames() {
"specifiedField",
"speedKph",
"startFieldName",
"startPickupDropOffWindow",
"startValue",
"stopCsvRowNumber",
"stopDesc",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.mobilitydata.gtfsvalidator.validator;

import static com.google.common.truth.Truth.assertThat;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import org.mobilitydata.gtfsvalidator.notice.NoticeContainer;
import org.mobilitydata.gtfsvalidator.table.GtfsStopTime;
import org.mobilitydata.gtfsvalidator.type.GtfsTime;

@RunWith(JUnit4.class)
public class PickupDropOffWindowValidatorTest {
@Test
public void shouldGenerateForbiddenArrivalOrDepartureTimeNotice() {
NoticeContainer noticeContainer = new NoticeContainer();
PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator();

GtfsStopTime stopTime =
new GtfsStopTime.Builder()
.setCsvRowNumber(1)
.setArrivalTime(GtfsTime.fromString("00:00:00"))
.setDepartureTime(GtfsTime.fromString("00:00:01"))
.setStartPickupDropOffWindow(GtfsTime.fromString("00:00:02"))
.setEndPickupDropOffWindow(GtfsTime.fromString("00:00:03"))
.build();
validator.validate(stopTime, noticeContainer);
assertThat(noticeContainer.getValidationNotices()).hasSize(1);
assertThat(noticeContainer.getValidationNotices().stream().findFirst().get())
.isInstanceOf(PickupDropOffWindowValidator.ForbiddenArrivalOrDepartureTimeNotice.class);
}

@Test
public void shouldGenerateMissingPickupOrDropOffWindowNotice_missingStart() {
NoticeContainer noticeContainer = new NoticeContainer();
PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator();

GtfsStopTime stopTime =
new GtfsStopTime.Builder()
.setCsvRowNumber(1)
.setEndPickupDropOffWindow(GtfsTime.fromString("00:00:03"))
.build();
validator.validate(stopTime, noticeContainer);
assertThat(noticeContainer.getValidationNotices()).hasSize(1);
assertThat(noticeContainer.getValidationNotices().stream().findFirst().get())
.isInstanceOf(PickupDropOffWindowValidator.MissingPickupOrDropOffWindowNotice.class);
}

@Test
public void shouldGenerateMissingPickupOrDropOffWindowNotice_missingEnd() {
NoticeContainer noticeContainer = new NoticeContainer();
PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator();

GtfsStopTime stopTime =
new GtfsStopTime.Builder()
.setCsvRowNumber(1)
.setStartPickupDropOffWindow(GtfsTime.fromString("00:00:03"))
.build();
validator.validate(stopTime, noticeContainer);
assertThat(noticeContainer.getValidationNotices()).hasSize(1);
assertThat(noticeContainer.getValidationNotices().stream().findFirst().get())
.isInstanceOf(PickupDropOffWindowValidator.MissingPickupOrDropOffWindowNotice.class);
}

@Test
public void shouldGenerateInvalidPickupDropOffWindowNotice() {
NoticeContainer noticeContainer = new NoticeContainer();
PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator();

GtfsStopTime stopTime =
new GtfsStopTime.Builder()
.setCsvRowNumber(1)
.setStartPickupDropOffWindow(GtfsTime.fromString("00:00:03"))
.setEndPickupDropOffWindow(GtfsTime.fromString("00:00:02"))
.build();
validator.validate(stopTime, noticeContainer);
assertThat(noticeContainer.getValidationNotices()).hasSize(1);
assertThat(noticeContainer.getValidationNotices().stream().findFirst().get())
.isInstanceOf(PickupDropOffWindowValidator.InvalidPickupDropOffWindowNotice.class);
}
}
Loading