Skip to content

Commit f65221e

Browse files
authored
fix: fix logic to compute service window (#2029)
1 parent 82e5d6b commit f65221e

6 files changed

Lines changed: 1118 additions & 121 deletions

File tree

main/src/main/java/org/mobilitydata/gtfsvalidator/reportsummary/model/FeedMetadata.java

Lines changed: 45 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,15 @@
55
import com.google.common.flogger.FluentLogger;
66
import com.vladsch.flexmark.util.misc.Pair;
77
import java.time.LocalDate;
8-
import java.time.format.DateTimeFormatter;
98
import java.util.*;
109
import java.util.function.Function;
10+
import java.util.stream.Stream;
1111
import org.mobilitydata.gtfsvalidator.performance.MemoryUsage;
1212
import org.mobilitydata.gtfsvalidator.performance.MemoryUsageRegister;
1313
import org.mobilitydata.gtfsvalidator.reportsummary.AgencyMetadata;
1414
import org.mobilitydata.gtfsvalidator.reportsummary.JsonReportCounts;
1515
import org.mobilitydata.gtfsvalidator.reportsummary.JsonReportFeedInfo;
1616
import org.mobilitydata.gtfsvalidator.table.*;
17-
import org.mobilitydata.gtfsvalidator.util.CalendarUtil;
18-
import org.mobilitydata.gtfsvalidator.util.ServicePeriod;
1917

2018
public class FeedMetadata {
2119

@@ -80,16 +78,28 @@ public static FeedMetadata from(GtfsFeedContainer feedContainer, ImmutableSet<St
8078
feedMetadata.loadAgencyData(agencyTableOptional.get());
8179
}
8280

83-
if (feedContainer.getTableForFilename(GtfsTrip.FILENAME).isPresent()
84-
&& (feedContainer.getTableForFilename(GtfsCalendar.FILENAME).isPresent()
85-
|| feedContainer.getTableForFilename(GtfsCalendarDate.FILENAME).isPresent())) {
81+
Optional<GtfsTripTableContainer> tripTableContainer =
82+
feedContainer
83+
.getTableForFilename(GtfsTrip.FILENAME)
84+
.filter(GtfsEntityContainer::isParsedSuccessfully)
85+
.map(c -> (GtfsTripTableContainer) c);
86+
87+
Optional<GtfsCalendarTableContainer> calendarTableContainer =
88+
feedContainer
89+
.getTableForFilename(GtfsCalendar.FILENAME)
90+
.filter(GtfsEntityContainer::isParsedSuccessfully)
91+
.map(c -> (GtfsCalendarTableContainer) c);
92+
93+
Optional<GtfsCalendarDateTableContainer> calendarDateTableContainer =
94+
feedContainer
95+
.getTableForFilename(GtfsCalendarDate.FILENAME)
96+
.filter(GtfsEntityContainer::isParsedSuccessfully)
97+
.map(c -> (GtfsCalendarDateTableContainer) c);
98+
99+
if (tripTableContainer.isPresent()
100+
&& (calendarTableContainer.isPresent() || calendarDateTableContainer.isPresent())) {
86101
feedMetadata.loadServiceWindow(
87-
(GtfsTableContainer<GtfsTrip, ?>)
88-
feedContainer.getTableForFilename(GtfsTrip.FILENAME).get(),
89-
(GtfsTableContainer<GtfsCalendar, ?>)
90-
feedContainer.getTableForFilename(GtfsCalendar.FILENAME).get(),
91-
(GtfsTableContainer<GtfsCalendarDate, ?>)
92-
feedContainer.getTableForFilename(GtfsCalendarDate.FILENAME).get());
102+
tripTableContainer.get(), calendarTableContainer, calendarDateTableContainer);
93103
}
94104

95105
feedMetadata.loadSpecFeatures(feedContainer);
@@ -726,129 +736,44 @@ private String checkLocalDate(LocalDate localDate) {
726736
/**
727737
* Loads the service date range by determining the earliest start date and the latest end date for
728738
* all services referenced with a trip\_id in `trips.txt`. It handles three cases: 1. When only
729-
* `calendars.txt` is used. 2. When only `calendar\_dates.txt` is used. 3. When both
730-
* `calendars.txt` and `calendar\_dates.txt` are used.
739+
* `calendar.txt` is used. 2. When only `calendar\_dates.txt` is used. 3. When both `calendar.txt`
740+
* and `calendar\_dates.txt` are used.
731741
*
732742
* @param tripContainer the container for `trips.txt` data
733-
* @param calendarTable the container for `calendars.txt` data
743+
* @param calendarTable the container for `calendar.txt` data
734744
* @param calendarDateTable the container for `calendar\_dates.txt` data
735745
*/
736746
public void loadServiceWindow(
737-
GtfsTableContainer<GtfsTrip, ?> tripContainer,
738-
GtfsTableContainer<GtfsCalendar, ?> calendarTable,
739-
GtfsTableContainer<GtfsCalendarDate, ?> calendarDateTable) {
740-
List<GtfsTrip> trips = tripContainer.getEntities();
741-
747+
GtfsTripTableContainer tripContainer,
748+
Optional<GtfsCalendarTableContainer> calendarTable,
749+
Optional<GtfsCalendarDateTableContainer> calendarDateTable) {
742750
LocalDate earliestStartDate = null;
743751
LocalDate latestEndDate = null;
744752
try {
745-
if ((calendarDateTable == null) && (calendarTable != null)) {
746-
// When only calendars.txt is used
747-
List<GtfsCalendar> calendars = calendarTable.getEntities();
748-
for (GtfsTrip trip : trips) {
749-
String serviceId = trip.serviceId();
750-
for (GtfsCalendar calendar : calendars) {
751-
if (calendar.serviceId().equals(serviceId)) {
752-
LocalDate startDate = calendar.startDate().getLocalDate();
753-
LocalDate endDate = calendar.endDate().getLocalDate();
754-
if (startDate != null || endDate != null) {
755-
if (startDate.toString().equals(LocalDate.EPOCH.toString())
756-
|| endDate.toString().equals(LocalDate.EPOCH.toString())) {
757-
continue;
758-
}
759-
if (earliestStartDate == null || startDate.isBefore(earliestStartDate)) {
760-
earliestStartDate = startDate;
761-
}
762-
if (latestEndDate == null || endDate.isAfter(latestEndDate)) {
763-
latestEndDate = endDate;
764-
}
765-
}
766-
}
767-
}
768-
}
769-
} else if ((calendarDateTable != null) && (calendarTable == null)) {
770-
// When only calendar_dates.txt is used
771-
List<GtfsCalendarDate> calendarDates = calendarDateTable.getEntities();
772-
for (GtfsTrip trip : trips) {
773-
String serviceId = trip.serviceId();
774-
for (GtfsCalendarDate calendarDate : calendarDates) {
775-
if (calendarDate.serviceId().equals(serviceId)) {
776-
LocalDate date = calendarDate.date().getLocalDate();
777-
if (date != null && !date.toString().equals(LocalDate.EPOCH.toString())) {
778-
if (earliestStartDate == null || date.isBefore(earliestStartDate)) {
779-
earliestStartDate = date;
780-
}
781-
if (latestEndDate == null || date.isAfter(latestEndDate)) {
782-
latestEndDate = date;
783-
}
784-
}
785-
}
786-
}
787-
}
788-
} else if ((calendarTable != null) && (calendarDateTable != null)) {
789-
// When both calendars.txt and calendar_dates.txt are used
790-
Map<String, ServicePeriod> servicePeriods =
791-
CalendarUtil.buildServicePeriodMap(
792-
(GtfsCalendarTableContainer) calendarTable,
793-
(GtfsCalendarDateTableContainer) calendarDateTable);
794-
List<LocalDate> removedDates = new ArrayList<>();
795-
for (GtfsTrip trip : trips) {
796-
String serviceId = trip.serviceId();
797-
ServicePeriod servicePeriod = servicePeriods.get(serviceId);
798-
LocalDate startDate = servicePeriod.getServiceStart();
799-
LocalDate endDate = servicePeriod.getServiceEnd();
800-
if (startDate != null && endDate != null) {
801-
if (startDate.toString().equals(LocalDate.EPOCH.toString())
802-
|| endDate.toString().equals(LocalDate.EPOCH.toString())) {
803-
continue;
804-
}
805-
if (earliestStartDate == null || startDate.isBefore(earliestStartDate)) {
806-
earliestStartDate = startDate;
807-
}
808-
if (latestEndDate == null || endDate.isAfter(latestEndDate)) {
809-
latestEndDate = endDate;
810-
}
811-
}
812-
removedDates.addAll(servicePeriod.getRemovedDays());
813-
}
814-
815-
for (LocalDate date : removedDates) {
816-
if (date.isEqual(earliestStartDate)) {
817-
earliestStartDate = date.plusDays(1);
818-
}
819-
if (date.isEqual(latestEndDate)) {
820-
latestEndDate = date.minusDays(1);
821-
}
822-
}
753+
Optional<ServiceWindow> serviceWindow =
754+
ServiceWindow.get(tripContainer, calendarTable, calendarDateTable);
755+
if (serviceWindow.isEmpty()) {
756+
logger.atWarning().log(
757+
"Could not compute service window. Check that `calendar.txt` and `calendar_dates.txt` contain data if they are present.");
823758
}
759+
earliestStartDate = serviceWindow.map(ServiceWindow::startDate).orElse(null);
760+
latestEndDate = serviceWindow.map(ServiceWindow::endDate).orElse(null);
824761
} catch (Exception e) {
825762
logger.atSevere().withCause(e).log("Error while loading Service Window");
826763
} finally {
827-
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMMM d, yyyy");
828-
if ((earliestStartDate == null) && (latestEndDate == null)) {
829-
feedInfo.put(JsonReportFeedInfo.FEED_INFO_SERVICE_WINDOW, "");
830-
} else if (earliestStartDate == null && latestEndDate != null) {
831-
feedInfo.put(JsonReportFeedInfo.FEED_INFO_SERVICE_WINDOW, latestEndDate.format(formatter));
832-
} else if (latestEndDate == null && earliestStartDate != null) {
833-
if (earliestStartDate.isAfter(latestEndDate)) {
834-
feedInfo.put(JsonReportFeedInfo.FEED_INFO_SERVICE_WINDOW, "");
835-
} else {
836-
feedInfo.put(
837-
JsonReportFeedInfo.FEED_INFO_SERVICE_WINDOW, earliestStartDate.format(formatter));
838-
}
839-
} else {
840-
StringBuilder serviceWindow = new StringBuilder();
841-
serviceWindow.append(earliestStartDate);
842-
serviceWindow.append(" to ");
843-
serviceWindow.append(latestEndDate);
844-
feedInfo.put(JsonReportFeedInfo.FEED_INFO_SERVICE_WINDOW, serviceWindow.toString());
845-
}
764+
String serviceWindowStr =
765+
String.join(
766+
" to ",
767+
Stream.of(earliestStartDate, latestEndDate)
768+
.filter(Objects::nonNull)
769+
.map(LocalDate::toString)
770+
.toList());
771+
feedInfo.put(JsonReportFeedInfo.FEED_INFO_SERVICE_WINDOW, serviceWindowStr);
846772
feedInfo.put(
847773
JsonReportFeedInfo.FEED_INFO_SERVICE_WINDOW_START,
848-
earliestStartDate == null ? "" : earliestStartDate.toString());
774+
Objects.toString(earliestStartDate, ""));
849775
feedInfo.put(
850-
JsonReportFeedInfo.FEED_INFO_SERVICE_WINDOW_END,
851-
latestEndDate == null ? "" : latestEndDate.toString());
776+
JsonReportFeedInfo.FEED_INFO_SERVICE_WINDOW_END, Objects.toString(latestEndDate, ""));
852777
}
853778
}
854779

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package org.mobilitydata.gtfsvalidator.reportsummary.model;
2+
3+
import static java.util.stream.Collectors.*;
4+
5+
import java.time.LocalDate;
6+
import java.util.*;
7+
import java.util.stream.Stream;
8+
import org.mobilitydata.gtfsvalidator.table.GtfsCalendar;
9+
import org.mobilitydata.gtfsvalidator.table.GtfsCalendarDate;
10+
import org.mobilitydata.gtfsvalidator.table.GtfsCalendarDateExceptionType;
11+
import org.mobilitydata.gtfsvalidator.table.GtfsCalendarDateTableContainer;
12+
import org.mobilitydata.gtfsvalidator.table.GtfsCalendarService;
13+
import org.mobilitydata.gtfsvalidator.table.GtfsCalendarTableContainer;
14+
import org.mobilitydata.gtfsvalidator.table.GtfsTripTableContainer;
15+
import org.mobilitydata.gtfsvalidator.util.SetUtil;
16+
17+
record ServiceWindow(LocalDate startDate, LocalDate endDate) {
18+
/**
19+
* Given a list of calendars, get the service window.
20+
*
21+
* @return The service window if there's at least one calendar, and empty otherwise.
22+
*/
23+
private static Optional<ServiceWindow> fromCalendars(List<GtfsCalendar> calendars) {
24+
List<LocalDate> activeDatesForCalendars =
25+
calendars.stream().flatMap(c -> getActiveDatesForCalendar(c).stream()).toList();
26+
// Only empty if there are no calendars or no active weekdays for
27+
// any of the calendars.
28+
Optional<LocalDate> startDate = activeDatesForCalendars.stream().min(LocalDate::compareTo);
29+
Optional<LocalDate> endDate = activeDatesForCalendars.stream().max(LocalDate::compareTo);
30+
return startDate.map(d -> new ServiceWindow(d, endDate.get()));
31+
}
32+
33+
/**
34+
* Given a list of calendar dates, get the service window.
35+
*
36+
* @return The service window if there's at least one date on which service is available, and
37+
* empty otherwise.
38+
*/
39+
private static Optional<ServiceWindow> fromCalendarDates(
40+
List<GtfsCalendarDate> allCalendarDates) {
41+
List<LocalDate> calendarDates =
42+
allCalendarDates.stream()
43+
.filter(d -> d.exceptionType() == GtfsCalendarDateExceptionType.SERVICE_ADDED)
44+
.map(d -> d.date().getLocalDate())
45+
.toList();
46+
47+
// Only empty if there are no calendar dates.
48+
Optional<LocalDate> startDate = calendarDates.stream().min(LocalDate::compareTo);
49+
Optional<LocalDate> endDate = calendarDates.stream().max(LocalDate::compareTo);
50+
return startDate.map(d -> new ServiceWindow(d, endDate.get()));
51+
}
52+
53+
/**
54+
* Given a list of calendars, map each date to the services it's in range for.
55+
*
56+
* <p>This doesn't take exceptions into account.
57+
*
58+
* @return The set of service ids for each date.
59+
*/
60+
private static Map<LocalDate, Set<String>> getServiceIdsByDateFromCalendars(
61+
List<GtfsCalendar> calendars) {
62+
Map<LocalDate, Set<String>> serviceIdsByDate = new HashMap<>();
63+
64+
for (GtfsCalendar calendar : calendars) {
65+
for (LocalDate date : getActiveDatesForCalendar(calendar)) {
66+
Set<String> serviceIdsForDate = serviceIdsByDate.getOrDefault(date, new HashSet<>());
67+
serviceIdsForDate.add(calendar.serviceId());
68+
serviceIdsByDate.put(date, serviceIdsForDate);
69+
}
70+
}
71+
72+
return serviceIdsByDate;
73+
}
74+
75+
/**
76+
* Given some calendars and calendar dates, get the service window. Removed dates are only taken
77+
* into account if they apply to all relevant services.
78+
*
79+
* @return The service window if there's at least one date with service, and empty otherwise.
80+
*/
81+
private static Optional<ServiceWindow> fromCalendarsAndCalendarDates(
82+
List<GtfsCalendar> calendars, List<GtfsCalendarDate> calendarDates) {
83+
Map<LocalDate, Set<String>> serviceIdsByDateFromCalendars =
84+
getServiceIdsByDateFromCalendars(calendars);
85+
86+
// Dates added to at least one service via an exception. We don't check for
87+
// contradicting exceptions.
88+
Set<LocalDate> addedDates =
89+
calendarDates.stream()
90+
.filter(d -> d.exceptionType() == GtfsCalendarDateExceptionType.SERVICE_ADDED)
91+
.map(d -> d.date().getLocalDate())
92+
.collect(toSet());
93+
94+
// Dates removed from all relevant services via an exception.
95+
Set<LocalDate> removedDates =
96+
calendarDates.stream()
97+
.filter(d -> d.exceptionType() == GtfsCalendarDateExceptionType.SERVICE_REMOVED)
98+
.collect(
99+
groupingBy(
100+
d -> d.date().getLocalDate(), mapping(GtfsCalendarDate::serviceId, toSet())))
101+
.entrySet()
102+
.stream()
103+
.filter(
104+
serviceIdsForDate -> {
105+
LocalDate date = serviceIdsForDate.getKey();
106+
Set<String> serviceIds = serviceIdsForDate.getValue();
107+
// If the date is in `addedDates`, we know there's at least one service
108+
// available on that date.
109+
return serviceIds.equals(serviceIdsByDateFromCalendars.get(date));
110+
})
111+
.map(Map.Entry::getKey)
112+
.collect(toSet());
113+
114+
Set<LocalDate> servicedDates =
115+
SetUtil.union(
116+
SetUtil.difference(serviceIdsByDateFromCalendars.keySet(), removedDates), addedDates);
117+
118+
// Only empty if there are no serviced dates.
119+
Optional<LocalDate> startDate = servicedDates.stream().min(LocalDate::compareTo);
120+
Optional<LocalDate> endDate = servicedDates.stream().max(LocalDate::compareTo);
121+
return startDate.map(d -> new ServiceWindow(d, endDate.get()));
122+
}
123+
124+
/**
125+
* Given some calendars and/or calendar dates, get the service window.
126+
*
127+
* @return The service window if there's at least one date on which service is available, and
128+
* empty otherwise.
129+
*/
130+
static Optional<ServiceWindow> get(
131+
GtfsTripTableContainer tripTable,
132+
Optional<GtfsCalendarTableContainer> calendarTable,
133+
Optional<GtfsCalendarDateTableContainer> calendarDateTable) {
134+
135+
Optional<List<GtfsCalendar>> calendars =
136+
calendarTable
137+
.map(GtfsCalendarTableContainer::getEntities)
138+
.map(List::stream)
139+
.map(cs -> cs.filter(c -> !tripTable.byServiceId(c.serviceId()).isEmpty()))
140+
.map(Stream::toList);
141+
Optional<List<GtfsCalendarDate>> calendarDates =
142+
calendarDateTable
143+
.map(GtfsCalendarDateTableContainer::getEntities)
144+
.map(List::stream)
145+
.map(ds -> ds.filter(d -> !tripTable.byServiceId(d.serviceId()).isEmpty()))
146+
.map(Stream::toList);
147+
148+
if (calendarDates.isEmpty() && calendars.isPresent()) {
149+
return ServiceWindow.fromCalendars(calendars.get());
150+
}
151+
152+
if (calendarDates.isPresent() && calendars.isEmpty()) {
153+
return ServiceWindow.fromCalendarDates(calendarDates.get());
154+
}
155+
156+
if (calendars.isPresent() && calendarDates.isPresent()) {
157+
return ServiceWindow.fromCalendarsAndCalendarDates(calendars.get(), calendarDates.get());
158+
}
159+
160+
return Optional.empty();
161+
}
162+
163+
/**
164+
* Given a calendar, get the dates that are active based on the service interval and on weekday
165+
* patterns.
166+
*/
167+
private static List<LocalDate> getActiveDatesForCalendar(GtfsCalendar calendar) {
168+
LocalDate startDate = calendar.startDate().getLocalDate();
169+
LocalDate endDate = calendar.endDate().getLocalDate();
170+
171+
return startDate.datesUntil(endDate.plusDays(1)).toList().stream()
172+
.filter(
173+
d ->
174+
switch (d.getDayOfWeek()) {
175+
case MONDAY -> calendar.monday();
176+
case TUESDAY -> calendar.tuesday();
177+
case WEDNESDAY -> calendar.wednesday();
178+
case THURSDAY -> calendar.thursday();
179+
case FRIDAY -> calendar.friday();
180+
case SATURDAY -> calendar.saturday();
181+
case SUNDAY -> calendar.sunday();
182+
}
183+
== GtfsCalendarService.AVAILABLE)
184+
.toList();
185+
}
186+
}

0 commit comments

Comments
 (0)