diff --git a/pom.xml b/pom.xml index e8ab9b6c9be..61ab09c6633 100644 --- a/pom.xml +++ b/pom.xml @@ -690,6 +690,11 @@ commons-math3 3.6.1 + + io.leonard + opening-hours-evaluator + 1.2.1 + com.graphql-java graphql-java diff --git a/src/main/java/org/opentripplanner/api/mapping/PlaceMapper.java b/src/main/java/org/opentripplanner/api/mapping/PlaceMapper.java index 3a7f05f1746..9a977db99e6 100644 --- a/src/main/java/org/opentripplanner/api/mapping/PlaceMapper.java +++ b/src/main/java/org/opentripplanner/api/mapping/PlaceMapper.java @@ -87,6 +87,7 @@ private ApiVehicleParkingWithEntrance mapVehicleParking(VehicleParkingWithEntran .hasWheelchairAccessibleCarPlaces(vp.hasWheelchairAccessibleCarPlaces()) .availability(mapVehicleParkingSpaces(vp.getAvailability())) .capacity(mapVehicleParkingSpaces(vp.getCapacity())) + .closesSoon(vehicleParkingWithEntrance.isClosesSoon()) .realtime(vehicleParkingWithEntrance.isRealtime()) .build(); } diff --git a/src/main/java/org/opentripplanner/api/model/ApiVehicleParkingWithEntrance.java b/src/main/java/org/opentripplanner/api/model/ApiVehicleParkingWithEntrance.java index 45757ddd63d..03c42c2a480 100644 --- a/src/main/java/org/opentripplanner/api/model/ApiVehicleParkingWithEntrance.java +++ b/src/main/java/org/opentripplanner/api/model/ApiVehicleParkingWithEntrance.java @@ -78,6 +78,14 @@ public class ApiVehicleParkingWithEntrance { */ public final ApiVehicleParkingSpaces availability; + /** + * True if the difference of visiting time for a {@link org.opentripplanner.routing.vehicle_parking.VehicleParking + * VehicleParking} and the closing time is inside the request's {@link + * org.opentripplanner.routing.api.request.RoutingRequest#vehicleParkingClosesSoonSeconds + * RoutingRequest#vehicleParkingClosesSoonSeconds} interval. + */ + public final boolean closesSoon; + /** * True if realtime information is used for checking availability. */ @@ -98,6 +106,7 @@ public class ApiVehicleParkingWithEntrance { boolean hasWheelchairAccessibleCarPlaces, ApiVehicleParkingSpaces capacity, ApiVehicleParkingSpaces availability, + boolean closesSoon, boolean realtime ) { this.id = id; @@ -114,6 +123,7 @@ public class ApiVehicleParkingWithEntrance { this.hasWheelchairAccessibleCarPlaces = hasWheelchairAccessibleCarPlaces; this.capacity = capacity; this.availability = availability; + this.closesSoon = closesSoon; this.realtime = realtime; } @@ -137,6 +147,7 @@ public static class ApiVehicleParkingWithEntranceBuilder { private boolean hasWheelchairAccessibleCarPlaces; private ApiVehicleParkingSpaces capacity; private ApiVehicleParkingSpaces availability; + private boolean closesSoon; private boolean realtime; ApiVehicleParkingWithEntranceBuilder() {} @@ -221,6 +232,12 @@ public ApiVehicleParkingWithEntranceBuilder availability( return this; } + public ApiVehicleParkingWithEntranceBuilder closesSoon( + boolean closesSoon + ) { + this.closesSoon = closesSoon; + return this; + } public ApiVehicleParkingWithEntranceBuilder realtime( boolean realtime @@ -233,7 +250,7 @@ public ApiVehicleParkingWithEntrance build() { return new ApiVehicleParkingWithEntrance( id, name, entranceId, entranceName, detailsUrl, imageUrl, note, tags, hasBicyclePlaces, hasAnyCarPlaces, hasCarPlaces, - hasWheelchairAccessibleCarPlaces, capacity, availability, realtime + hasWheelchairAccessibleCarPlaces, capacity, availability, closesSoon, realtime ); } } diff --git a/src/main/java/org/opentripplanner/api/resource/PlannerResource.java b/src/main/java/org/opentripplanner/api/resource/PlannerResource.java index b682b2e1e6c..67f5eb8bd4d 100644 --- a/src/main/java/org/opentripplanner/api/resource/PlannerResource.java +++ b/src/main/java/org/opentripplanner/api/resource/PlannerResource.java @@ -97,7 +97,7 @@ public TripPlannerResponse plan(@Context UriInfo uriInfo, @Context Request grizz response.debugOutput = res.getDebugTimingAggregator().finishedRendering(); } - catch (Exception e) { + catch (Throwable e) { LOG.error("System error", e); PlannerError error = new PlannerError(Message.SYSTEM_ERROR); response.setError(error); diff --git a/src/main/java/org/opentripplanner/graph_builder/issues/ParkAndRideOpeningHoursUnparsed.java b/src/main/java/org/opentripplanner/graph_builder/issues/ParkAndRideOpeningHoursUnparsed.java new file mode 100644 index 00000000000..69ffe6c3f09 --- /dev/null +++ b/src/main/java/org/opentripplanner/graph_builder/issues/ParkAndRideOpeningHoursUnparsed.java @@ -0,0 +1,31 @@ +package org.opentripplanner.graph_builder.issues; + +import org.opentripplanner.graph_builder.DataImportIssue; +import org.opentripplanner.openstreetmap.model.OSMWithTags; + +public class ParkAndRideOpeningHoursUnparsed implements DataImportIssue { + + public static final String FMT = "Park and ride '%s' (%s) has an invalid opening_hours value, it will always be open: %s"; + public static final String HTMLFMT = "Park and ride '%s' (%s) has an invalid opening_hours value, it will always be open: %s\""; + + private final String name; + private final OSMWithTags entity; + private final String openingHours; + + public ParkAndRideOpeningHoursUnparsed(String name, OSMWithTags entity, String openingHours){ + this.name = name; + this.entity = entity; + this.openingHours = openingHours; + } + + @Override + public String getMessage() { + return String.format(FMT, name, entity, openingHours); + } + + @Override + public String getHTMLMessage() { + return String.format(HTMLFMT, entity.getOpenStreetMapLink(), name, entity, openingHours); + } + +} diff --git a/src/main/java/org/opentripplanner/graph_builder/module/osm/OpenStreetMapModule.java b/src/main/java/org/opentripplanner/graph_builder/module/osm/OpenStreetMapModule.java index 72da3a18d00..dfa35a1e654 100644 --- a/src/main/java/org/opentripplanner/graph_builder/module/osm/OpenStreetMapModule.java +++ b/src/main/java/org/opentripplanner/graph_builder/module/osm/OpenStreetMapModule.java @@ -1,5 +1,6 @@ package org.opentripplanner.graph_builder.module.osm; +import ch.poole.openinghoursparser.OpeningHoursParseException; import com.google.common.collect.Iterables; import gnu.trove.iterator.TLongIterator; import gnu.trove.list.TLongList; @@ -27,6 +28,7 @@ import org.opentripplanner.graph_builder.DataImportIssueStore; import org.opentripplanner.graph_builder.issues.Graphwide; import org.opentripplanner.graph_builder.issues.InvalidVehicleParkingCapacity; +import org.opentripplanner.graph_builder.issues.ParkAndRideOpeningHoursUnparsed; import org.opentripplanner.graph_builder.issues.ParkAndRideUnlinked; import org.opentripplanner.graph_builder.issues.StreetCarSpeedZero; import org.opentripplanner.graph_builder.issues.TurnRestrictionBad; @@ -43,6 +45,8 @@ import org.opentripplanner.openstreetmap.model.OSMWay; import org.opentripplanner.openstreetmap.model.OSMWithTags; import org.opentripplanner.routing.api.request.RoutingRequest; +import org.opentripplanner.routing.core.OsmOpeningHours; +import org.opentripplanner.routing.core.TimeRestriction; import org.opentripplanner.routing.core.TraversalRequirements; import org.opentripplanner.routing.core.TraverseMode; import org.opentripplanner.routing.edgetype.AreaEdge; @@ -544,6 +548,8 @@ private VehicleParking createVehicleParkingObjectFromOsmEntity( ) || carCapacity.orElse(0) > 0; var wheelchairAccessibleCarPlaces = wheelchairAccessibleCarCapacity.orElse(0) > 0; + var openingHours = parseVehicleParkingOpeningHours(entity, creativeName); + var id = new FeedScopedId( VEHICLE_PARKING_OSM_FEED_ID, String.format("%s/%d", entity.getClass().getSimpleName(), entity.getId()) @@ -573,6 +579,7 @@ private VehicleParking createVehicleParkingObjectFromOsmEntity( .y(lat) .tags(tags) .detailsUrl(entity.getTag("website")) + .openingHours(openingHours) .bicyclePlaces(bicyclePlaces) .carPlaces(carPlaces) .wheelchairAccessibleCarPlaces(wheelchairAccessibleCarPlaces) @@ -581,6 +588,20 @@ private VehicleParking createVehicleParkingObjectFromOsmEntity( .build(); } + private TimeRestriction parseVehicleParkingOpeningHours(OSMWithTags entity, I18NString creativeName) { + final var openingHoursTag = entity.getTag("opening_hours"); + if (openingHoursTag != null) { + try { + return OsmOpeningHours.parseFromOsm(openingHoursTag); + } catch (OpeningHoursParseException e) { + issueStore.add(new ParkAndRideOpeningHoursUnparsed( + creativeName.toString(), entity, openingHoursTag + )); + } + } + return null; + } + private I18NString nameParkAndRideEntity(OSMWithTags osmWithTags) { // If there is an explicit name user that. The explicit name is used so that tag-based // translations are used, which are not handled by "CreativeNamer"s. diff --git a/src/main/java/org/opentripplanner/model/plan/Place.java b/src/main/java/org/opentripplanner/model/plan/Place.java index 38d4bd6bbd3..e062dc032bc 100644 --- a/src/main/java/org/opentripplanner/model/plan/Place.java +++ b/src/main/java/org/opentripplanner/model/plan/Place.java @@ -206,7 +206,12 @@ public static Place forVehicleRentalPlace(VehicleRentalStationVertex vertex, Str ); } - public static Place forVehicleParkingEntrance(VehicleParkingEntranceVertex vertex, String name, RoutingRequest request) { + public static Place forVehicleParkingEntrance( + VehicleParkingEntranceVertex vertex, + String name, + boolean closesSoon, + RoutingRequest request + ) { TraverseMode traverseMode = null; if (request.streetSubRequestModes.getCar()) { traverseMode = TraverseMode.CAR; @@ -228,6 +233,7 @@ public static Place forVehicleParkingEntrance(VehicleParkingEntranceVertex verte VehicleParkingWithEntrance.builder() .vehicleParking(vertex.getVehicleParking()) .entrance(vertex.getParkingEntrance()) + .closesSoon(closesSoon) .realtime(realTime) .build() ); diff --git a/src/main/java/org/opentripplanner/model/plan/VehicleParkingWithEntrance.java b/src/main/java/org/opentripplanner/model/plan/VehicleParkingWithEntrance.java index cefdf451404..a0179d6f947 100644 --- a/src/main/java/org/opentripplanner/model/plan/VehicleParkingWithEntrance.java +++ b/src/main/java/org/opentripplanner/model/plan/VehicleParkingWithEntrance.java @@ -9,6 +9,14 @@ public class VehicleParkingWithEntrance { private final VehicleParkingEntrance entrance; + /** + * True if the difference of visiting time for a {@link org.opentripplanner.routing.vehicle_parking.VehicleParking + * VehicleParking} and the closing time is inside the request's {@link + * org.opentripplanner.routing.api.request.RoutingRequest#vehicleParkingClosesSoonSeconds + * RoutingRequest#vehicleParkingClosesSoonSeconds} interval. + */ + public final boolean closesSoon; + /** * Was realtime data used when parking at this VehicleParking. */ @@ -17,10 +25,12 @@ public class VehicleParkingWithEntrance { VehicleParkingWithEntrance( VehicleParking vehicleParking, VehicleParkingEntrance entrance, + boolean closesSoon, boolean realtime ) { this.vehicleParking = vehicleParking; this.entrance = entrance; + this.closesSoon = closesSoon; this.realtime = realtime; } @@ -32,6 +42,10 @@ public VehicleParkingEntrance getEntrance() { return this.entrance; } + public boolean isClosesSoon() { + return closesSoon; + } + public boolean isRealtime() { return realtime; } @@ -44,6 +58,7 @@ public static class VehicleParkingWithEntranceBuilder { private VehicleParking vehicleParking; private VehicleParkingEntrance entrance; + private boolean closesSoon; private boolean realtime; VehicleParkingWithEntranceBuilder() {} @@ -62,6 +77,13 @@ public VehicleParkingWithEntranceBuilder entrance( return this; } + public VehicleParkingWithEntranceBuilder closesSoon( + boolean closesSoon + ) { + this.closesSoon = closesSoon; + return this; + } + public VehicleParkingWithEntranceBuilder realtime( boolean realtime ) { @@ -70,7 +92,7 @@ public VehicleParkingWithEntranceBuilder realtime( } public VehicleParkingWithEntrance build() { - return new VehicleParkingWithEntrance(vehicleParking, entrance, realtime); + return new VehicleParkingWithEntrance(vehicleParking, entrance, closesSoon, realtime); } } } diff --git a/src/main/java/org/opentripplanner/routing/algorithm/mapping/GraphPathToItineraryMapper.java b/src/main/java/org/opentripplanner/routing/algorithm/mapping/GraphPathToItineraryMapper.java index b1c0761d51d..a9f804dfa18 100644 --- a/src/main/java/org/opentripplanner/routing/algorithm/mapping/GraphPathToItineraryMapper.java +++ b/src/main/java/org/opentripplanner/routing/algorithm/mapping/GraphPathToItineraryMapper.java @@ -503,7 +503,17 @@ private static Place makePlace(State state, Locale requestedLocale) { } else if(vertex instanceof VehicleRentalStationVertex) { return Place.forVehicleRentalPlace((VehicleRentalStationVertex) vertex, name); } else if (vertex instanceof VehicleParkingEntranceVertex) { - return Place.forVehicleParkingEntrance((VehicleParkingEntranceVertex) vertex, name, state.getOptions()); + var vehicleParking = ((VehicleParkingEntranceVertex) vertex).getVehicleParking(); + var limit = state.getTimeAsZonedDateTime() + .plusSeconds(state.getOptions().vehicleParkingClosesSoonSeconds); + var closesSoon = false; + if (vehicleParking.getOpeningHours() != null) { + // This ignores the parking being closed for less than vehicleParkingClosesSoonSeconds + closesSoon = !vehicleParking.getOpeningHours() + .isTraverseableAt(limit.toLocalDateTime()); + } + return Place.forVehicleParkingEntrance( + (VehicleParkingEntranceVertex) vertex, name, closesSoon, state.getOptions()); } else { return Place.normal(vertex, name); } diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/router/TransitRouter.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/router/TransitRouter.java index 0d39de93d75..bdcbe23defe 100644 --- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/router/TransitRouter.java +++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/router/TransitRouter.java @@ -82,7 +82,7 @@ private TransitRouterResult route() { debugTimingAggregator.finishedPatternFiltering(); - var accessEgresses = getAccessEgresses(transitLayer); + var accessEgresses = getAccessEgresses(transitLayer, searchStartTime); debugTimingAggregator.finishedAccessEgress( accessEgresses.getAccesses().size(), @@ -149,7 +149,8 @@ private TransitRouterResult route() { } private AccessEgresses getAccessEgresses( - TransitLayer transitLayer + TransitLayer transitLayer, + ZonedDateTime startOfTime ) { var accessEgressMapper = new AccessEgressMapper(transitLayer.getStopIndex()); var accessList = new ArrayList(); @@ -157,13 +158,13 @@ private AccessEgresses getAccessEgresses( var accessCalculator = (Runnable) () -> { debugTimingAggregator.startedAccessCalculating(); - accessList.addAll(getAccessEgresses(accessEgressMapper, false)); + accessList.addAll(getAccessEgresses(accessEgressMapper, startOfTime, false)); debugTimingAggregator.finishedAccessCalculating(); }; var egressCalculator = (Runnable) () -> { debugTimingAggregator.startedEgressCalculating(); - egressList.addAll(getAccessEgresses(accessEgressMapper, true)); + egressList.addAll(getAccessEgresses(accessEgressMapper, startOfTime, true)); debugTimingAggregator.finishedEgressCalculating(); }; @@ -194,7 +195,7 @@ private AccessEgresses getAccessEgresses( private Collection getAccessEgresses( AccessEgressMapper accessEgressMapper, - boolean isEgress + ZonedDateTime startOfTime, boolean isEgress ) { var results = new ArrayList(); var mode = isEgress ? request.modes.egressMode : request.modes.accessMode; @@ -213,7 +214,7 @@ private Collection getAccessEgresses( isEgress ); - results.addAll(accessEgressMapper.mapNearbyStops(nearbyStops, isEgress)); + results.addAll(accessEgressMapper.mapNearbyStops(nearbyStops, startOfTime, isEgress)); // Special handling of flex accesses if (OTPFeature.FlexRouting.isOn() && mode == StreetMode.FLEXIBLE) { @@ -223,7 +224,7 @@ private Collection getAccessEgresses( isEgress ); - results.addAll(accessEgressMapper.mapFlexAccessEgresses(flexAccessList, isEgress)); + results.addAll(accessEgressMapper.mapFlexAccessEgresses(flexAccessList, startOfTime, isEgress)); } } diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/router/street/AccessEgressRouter.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/router/street/AccessEgressRouter.java index 49f9cd22384..ef7f637f89e 100644 --- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/router/street/AccessEgressRouter.java +++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/router/street/AccessEgressRouter.java @@ -40,6 +40,7 @@ public static Collection streetSearch( // the routingContext (rctx), which results in the created temporary edges being removed prematurely. // findNearbyStopsViaStreets() will call cleanup() on the created routing request. RoutingRequest nearbyRequest = rr.getStreetSearchRequest(streetMode); + nearbyRequest.ignoreAndCollectTimeRestrictions = true; NearbyStopFinder nearbyStopFinder = new NearbyStopFinder( rr.rctx.graph, diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/AccessEgress.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/AccessEgress.java index 11c058417f5..7400945e16c 100644 --- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/AccessEgress.java +++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/AccessEgress.java @@ -1,12 +1,17 @@ package org.opentripplanner.routing.algorithm.raptor.transit; +import java.time.Duration; +import java.time.ZonedDateTime; import org.opentripplanner.model.base.ToStringBuilder; import org.opentripplanner.routing.algorithm.raptor.transit.cost.RaptorCostConverter; import org.opentripplanner.routing.core.State; +import org.opentripplanner.routing.core.TimeRestrictionWithOffset; import org.opentripplanner.transit.raptor.api.transit.RaptorTransfer; public class AccessEgress implements RaptorTransfer { + private static final int MAX_TIME_RESTRICTION_ITERATIONS = 10; + /** * "To stop" in the case of access, "from stop" in the case of egress. */ @@ -21,11 +26,18 @@ public class AccessEgress implements RaptorTransfer { */ private final State lastState; - public AccessEgress(int toFromStop, State lastState) { + private final ZonedDateTime startOfTime; + + public AccessEgress( + int toFromStop, + State lastState, + ZonedDateTime startOfTime + ) { this.toFromStop = toFromStop; this.durationInSeconds = (int) lastState.getElapsedTimeSeconds(); this.generalizedCost = RaptorCostConverter.toRaptorCost(lastState.getWeight()); this.lastState = lastState; + this.startOfTime = startOfTime; } @Override @@ -43,6 +55,85 @@ public int durationInSeconds() { return durationInSeconds; } + @Override + public int earliestDepartureTime(int requestedDepartureTime) { + var timeRestrictions = getLastState().getTimeRestrictions(); + if (timeRestrictions.isEmpty()) { + return requestedDepartureTime; + } + + var time = startOfTime.plusSeconds(requestedDepartureTime) + .toLocalDateTime(); + var iterations = 0; + + DATETIME_SEARCH: + while (iterations < MAX_TIME_RESTRICTION_ITERATIONS) { + for (final TimeRestrictionWithOffset timeRestriction : timeRestrictions) { + var timeAtRestriction = time.plusSeconds(timeRestriction.getOffsetInSecondsFromStartOfSearch()); + var traversableAt = timeRestriction.getTimeRestriction() + .earliestDepartureTime(timeAtRestriction); + + if (traversableAt.isEmpty()) { + break DATETIME_SEARCH; + } + + var alternateTime = traversableAt.get(); + if (!alternateTime.equals(timeAtRestriction)) { + time = alternateTime.minusSeconds(timeRestriction.getOffsetInSecondsFromStartOfSearch()); + iterations++; + continue DATETIME_SEARCH; + } + } + + return (int) Duration.between( + startOfTime, + time.atZone(startOfTime.getZone()) + ).getSeconds(); + } + + return -1; + } + + @Override + public int latestArrivalTime(int requestedArrivalTime) { + var timeRestrictions = getLastState().getTimeRestrictions(); + if (timeRestrictions.isEmpty()) { + return requestedArrivalTime; + } + + var time = startOfTime.plusSeconds(requestedArrivalTime) + .toLocalDateTime(); + var iterations = 0; + + DATETIME_SEARCH: + while (iterations < MAX_TIME_RESTRICTION_ITERATIONS) { + for (final TimeRestrictionWithOffset timeRestriction : timeRestrictions) { + var offsetFromArrival = timeRestriction.getOffsetInSecondsFromStartOfSearch() - durationInSeconds; + var timeAtRestriction = time.plusSeconds(offsetFromArrival); + var traversableAt = timeRestriction.getTimeRestriction() + .latestArrivalTime(timeAtRestriction); + + if (traversableAt.isEmpty()) { + break DATETIME_SEARCH; + } + + var alternateTime = traversableAt.get(); + if (!alternateTime.equals(timeAtRestriction)) { + time = alternateTime.minusSeconds(offsetFromArrival); + iterations++; + continue DATETIME_SEARCH; + } + } + + return (int) Duration.between( + startOfTime, + time.atZone(startOfTime.getZone()) + ).getSeconds(); + } + + return -1; + } + public State getLastState() { return lastState; } diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/FlexAccessEgressAdapter.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/FlexAccessEgressAdapter.java index ce8c7f1b4af..7f0055a1a41 100644 --- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/FlexAccessEgressAdapter.java +++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/FlexAccessEgressAdapter.java @@ -1,5 +1,6 @@ package org.opentripplanner.routing.algorithm.raptor.transit; +import java.time.ZonedDateTime; import org.opentripplanner.ext.flex.FlexAccessEgress; /** @@ -9,11 +10,15 @@ public class FlexAccessEgressAdapter extends AccessEgress { private final FlexAccessEgress flexAccessEgress; public FlexAccessEgressAdapter( - FlexAccessEgress flexAccessEgress, boolean isEgress, StopIndexForRaptor stopIndex + FlexAccessEgress flexAccessEgress, + StopIndexForRaptor stopIndex, + ZonedDateTime startOfTime, + boolean isEgress ) { super( stopIndex.indexByStop.get(flexAccessEgress.stop), - isEgress ? flexAccessEgress.lastState.reverse() : flexAccessEgress.lastState + isEgress ? flexAccessEgress.lastState.reverse() : flexAccessEgress.lastState, + startOfTime ); this.flexAccessEgress = flexAccessEgress; diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/AccessEgressMapper.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/AccessEgressMapper.java index 882ced15f3a..3f53c2575d2 100644 --- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/AccessEgressMapper.java +++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/AccessEgressMapper.java @@ -1,5 +1,6 @@ package org.opentripplanner.routing.algorithm.raptor.transit.mappers; +import java.time.ZonedDateTime; import org.opentripplanner.ext.flex.FlexAccessEgress; import org.opentripplanner.model.Stop; import org.opentripplanner.routing.algorithm.raptor.transit.AccessEgress; @@ -20,28 +21,30 @@ public AccessEgressMapper(StopIndexForRaptor stopIndex) { this.stopIndex = stopIndex; } - public AccessEgress mapNearbyStop(NearbyStop nearbyStop, boolean isEgress) { + public AccessEgress mapNearbyStop(NearbyStop nearbyStop, ZonedDateTime startOfTime, boolean isEgress) { if (!(nearbyStop.stop instanceof Stop)) { return null; } return new AccessEgress( stopIndex.indexByStop.get(nearbyStop.stop), - isEgress ? nearbyStop.state.reverse() : nearbyStop.state + isEgress ? nearbyStop.state.reverse() : nearbyStop.state, + startOfTime ); } - public List mapNearbyStops(Collection accessStops, boolean isEgress) { + public List mapNearbyStops(Collection accessStops, ZonedDateTime startOfTime, boolean isEgress) { return accessStops .stream() - .map(stopAtDistance -> mapNearbyStop(stopAtDistance, isEgress)) + .map(stopAtDistance -> mapNearbyStop(stopAtDistance, startOfTime, isEgress)) .filter(Objects::nonNull) .collect(Collectors.toList()); } public Collection mapFlexAccessEgresses( Collection flexAccessEgresses, + ZonedDateTime startOfTime, boolean isEgress ) { return flexAccessEgresses.stream() - .map(flexAccessEgress -> new FlexAccessEgressAdapter(flexAccessEgress, isEgress, stopIndex)) + .map(flexAccessEgress -> new FlexAccessEgressAdapter(flexAccessEgress, stopIndex, startOfTime, isEgress)) .collect(Collectors.toList()); } diff --git a/src/main/java/org/opentripplanner/routing/api/request/RoutingRequest.java b/src/main/java/org/opentripplanner/routing/api/request/RoutingRequest.java index fb85507ee9d..80eddcb2cdb 100644 --- a/src/main/java/org/opentripplanner/routing/api/request/RoutingRequest.java +++ b/src/main/java/org/opentripplanner/routing/api/request/RoutingRequest.java @@ -40,6 +40,7 @@ import org.opentripplanner.routing.core.RouteMatcher; import org.opentripplanner.routing.core.RoutingContext; import org.opentripplanner.routing.core.State; +import org.opentripplanner.routing.core.TimeRestrictionWithOffset; import org.opentripplanner.routing.core.TraverseMode; import org.opentripplanner.routing.core.TraverseModeSet; import org.opentripplanner.routing.core.intersection_model.IntersectionTraversalCostModel; @@ -396,6 +397,19 @@ public class RoutingRequest implements AutoCloseable, Cloneable, Serializable { /** Tags with which a vehicle parking will not be used. If empty, no tags are banned. */ public Set bannedVehicleParkingTags = Set.of(); + /** + * If the opening hours should be taken into account for vehicle parkings. If true, it is not + * possible to park outside of opening hours. + */ + public boolean useVehicleParkingOpeningHours = true; + + /** + * If the visiting time for a {@link org.opentripplanner.routing.vehicle_parking.VehicleParking VehicleParking} + * is inside this interval, then the {@link org.opentripplanner.api.model.ApiVehicleParkingWithEntrance#closesSoon ApiVehicleParkingWithEntrance#closesSoon} + * flag will be marked true. Defaults to 30 minutes. + */ + public int vehicleParkingClosesSoonSeconds = 30 * 60; + /** * Time to park a car in a park and ride, w/o taking into account driving and walking cost * (time to park, switch off, pick your stuff, lock the car, etc...) @@ -694,6 +708,14 @@ public class RoutingRequest implements AutoCloseable, Cloneable, Serializable { public Set allowedRentalFormFactors = new HashSet<>(); + /** + * If {@code true}, then {@link org.opentripplanner.routing.core.TimeRestriction} on edges will + * be ignored and collected using {@link org.opentripplanner.routing.core.StateEditor#addTimeRestriction(TimeRestrictionWithOffset, + * Object)}. This is used to construct {@link org.opentripplanner.routing.algorithm.raptor.transit.AccessEgress} + * objects for RAPTOR. + */ + public boolean ignoreAndCollectTimeRestrictions = false; + /** * If true vehicle parking availability information will be used to plan park and ride trips where it exists. */ diff --git a/src/main/java/org/opentripplanner/routing/core/OsmOpeningHours.java b/src/main/java/org/opentripplanner/routing/core/OsmOpeningHours.java new file mode 100644 index 00000000000..c36de465182 --- /dev/null +++ b/src/main/java/org/opentripplanner/routing/core/OsmOpeningHours.java @@ -0,0 +1,65 @@ +package org.opentripplanner.routing.core; + +import ch.poole.openinghoursparser.OpeningHoursParseException; +import ch.poole.openinghoursparser.OpeningHoursParser; +import ch.poole.openinghoursparser.Rule; +import ch.poole.openinghoursparser.Util; +import io.leonard.OpeningHoursEvaluator; +import java.io.ByteArrayInputStream; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import org.opentripplanner.util.I18NString; + +/** + * + */ +@EqualsAndHashCode +@RequiredArgsConstructor +public class OsmOpeningHours implements I18NString, TimeRestriction, Serializable { + + private static final int MAX_SEARCH_DAYS = 6; + + private final List rules; + + @Override + public boolean isTraverseableAt(LocalDateTime now) { + return OpeningHoursEvaluator.isOpenAt(now, rules); + } + + @Override + public Optional earliestDepartureTime(LocalDateTime now) { + return OpeningHoursEvaluator.isOpenNext(now, rules, MAX_SEARCH_DAYS); + } + + @Override + public Optional latestArrivalTime(LocalDateTime now) { + try { + return OpeningHoursEvaluator.wasLastOpen(now, rules, MAX_SEARCH_DAYS); + } catch (Throwable e) { + System.out.println("Err: " + this); + throw e; + } + } + + public static OsmOpeningHours parseFromOsm(String openingHours) + throws OpeningHoursParseException { + var parser = new OpeningHoursParser(new ByteArrayInputStream(openingHours.getBytes())); + var rules = parser.rules(true); + return new OsmOpeningHours(rules); + } + + @Override + public String toString(Locale locale) { + return toString(); + } + + @Override + public String toString() { + return Util.rulesToOpeningHoursString(rules); + } +} diff --git a/src/main/java/org/opentripplanner/routing/core/State.java b/src/main/java/org/opentripplanner/routing/core/State.java index 7a5c4d3923c..e7857c4898b 100644 --- a/src/main/java/org/opentripplanner/routing/core/State.java +++ b/src/main/java/org/opentripplanner/routing/core/State.java @@ -1,10 +1,15 @@ package org.opentripplanner.routing.core; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Collection; import java.util.Date; +import java.util.HashSet; +import java.util.List; import java.util.Objects; - +import java.util.Set; import org.opentripplanner.routing.algorithm.astar.NegativeWeightException; import org.opentripplanner.routing.api.request.RoutingRequest; import org.opentripplanner.routing.edgetype.StreetEdge; @@ -459,6 +464,8 @@ private State reversedClone() { newState.stateData.vehicleRentalState = stateData.vehicleRentalState; newState.stateData.vehicleParked = stateData.vehicleParked; newState.stateData.carPickupState = stateData.carPickupState; + newState.stateData.timeRestrictions = new ArrayList<>(getTimeRestrictions()); + newState.stateData.timeRestrictionSources = new HashSet<>(getTimeRestrictionSources()); return newState; } @@ -476,6 +483,16 @@ public long getTimeInMillis() { return time; } + public ZonedDateTime getTimeAsZonedDateTime() { + return Instant.ofEpochMilli(getTimeInMillis()) + .atZone(getOptions().rctx.graph.getTimeZone().toZoneId()); + } + + public LocalDateTime getTimeAsLocalDateTime() { + return getTimeAsZonedDateTime() + .toLocalDateTime(); + } + public boolean multipleOptionsBefore() { boolean foundAlternatePaths = false; TraverseMode requestedMode = getNonTransitMode(); @@ -611,4 +628,18 @@ public boolean hasEnteredNoThruTrafficArea() { public boolean mayKeepRentedVehicleAtDestination() { return stateData.mayKeepRentedVehicleAtDestination; } + + public List getTimeRestrictions() { + if (stateData.timeRestrictions == null) { + return List.of(); + } + return stateData.timeRestrictions; + } + + public Set getTimeRestrictionSources() { + if (stateData.timeRestrictions == null) { + return Set.of(); + } + return stateData.timeRestrictionSources; + } } diff --git a/src/main/java/org/opentripplanner/routing/core/StateData.java b/src/main/java/org/opentripplanner/routing/core/StateData.java index 1cbfaf607c8..8458ab9c832 100644 --- a/src/main/java/org/opentripplanner/routing/core/StateData.java +++ b/src/main/java/org/opentripplanner/routing/core/StateData.java @@ -1,8 +1,8 @@ package org.opentripplanner.routing.core; -import org.opentripplanner.routing.api.request.RoutingRequest; - +import java.util.List; import java.util.Set; +import org.opentripplanner.routing.api.request.RoutingRequest; import org.opentripplanner.routing.vehicle_rental.RentalVehicleType.FormFactor; /** @@ -25,6 +25,22 @@ public class StateData implements Cloneable { protected CarPickupState carPickupState; + /** + * Time restrictions encountered while traversing edges. + */ + protected List timeRestrictions; + + /** + * The sources of the time restrictions. This is used for state domination, so that + *

+ * 1) it is possible to have two {@link org.opentripplanner.routing.algorithm.raptor.transit.AccessEgress}es + * to the same stop, but with different {@link org.opentripplanner.routing.vehicle_parking.VehicleParking} + * entities. + * + * 2) states with the same restriction-source don't dominate each other. + */ + protected Set timeRestrictionSources; + protected RoutingRequest opt; /** diff --git a/src/main/java/org/opentripplanner/routing/core/StateEditor.java b/src/main/java/org/opentripplanner/routing/core/StateEditor.java index 51c111d969d..80c20fa0f08 100644 --- a/src/main/java/org/opentripplanner/routing/core/StateEditor.java +++ b/src/main/java/org/opentripplanner/routing/core/StateEditor.java @@ -1,8 +1,11 @@ package org.opentripplanner.routing.core; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import org.opentripplanner.routing.api.request.RoutingRequest; import org.opentripplanner.routing.graph.Edge; import org.opentripplanner.routing.graph.Vertex; -import org.opentripplanner.routing.api.request.RoutingRequest; import org.opentripplanner.routing.vehicle_rental.RentalVehicleType.FormFactor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -340,6 +343,10 @@ public long getTimeSeconds() { return child.getTimeSeconds(); } + public ZonedDateTime getZonedDateTime() { + return child.getTimeAsZonedDateTime(); + } + public long getElapsedTimeSeconds() { return child.getElapsedTimeSeconds(); } @@ -381,4 +388,17 @@ public void setBikeRentalNetwork(String network) { public State getBackState() { return child.getBackState(); } + + public void addTimeRestriction(TimeRestrictionWithOffset timeRestriction, Object source) { + if (child.getOptions().ignoreAndCollectTimeRestrictions) { + cloneStateDataAsNeeded(); + child.stateData.timeRestrictions = new ArrayList<>(); + child.stateData.timeRestrictions.addAll(child.backState.getTimeRestrictions()); + child.stateData.timeRestrictions.add(timeRestriction); + + child.stateData.timeRestrictionSources = new HashSet<>(); + child.stateData.timeRestrictionSources.addAll(child.backState.getTimeRestrictionSources()); + child.stateData.timeRestrictionSources.add(source); + } + } } diff --git a/src/main/java/org/opentripplanner/routing/core/TimeRestriction.java b/src/main/java/org/opentripplanner/routing/core/TimeRestriction.java new file mode 100644 index 00000000000..0ddcdbfd04d --- /dev/null +++ b/src/main/java/org/opentripplanner/routing/core/TimeRestriction.java @@ -0,0 +1,13 @@ +package org.opentripplanner.routing.core; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * A time restriction for use with edge traversal which limits when an edge may be traversed. + */ +public interface TimeRestriction { + boolean isTraverseableAt(LocalDateTime now); + Optional earliestDepartureTime(LocalDateTime now); + Optional latestArrivalTime(LocalDateTime now); +} diff --git a/src/main/java/org/opentripplanner/routing/core/TimeRestrictionWithOffset.java b/src/main/java/org/opentripplanner/routing/core/TimeRestrictionWithOffset.java new file mode 100644 index 00000000000..0423bf31eed --- /dev/null +++ b/src/main/java/org/opentripplanner/routing/core/TimeRestrictionWithOffset.java @@ -0,0 +1,11 @@ +package org.opentripplanner.routing.core; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(staticName = "of") +public class TimeRestrictionWithOffset { + private final TimeRestriction timeRestriction; + private final long offsetInSecondsFromStartOfSearch; +} diff --git a/src/main/java/org/opentripplanner/routing/core/TimeRestrictionWithTimeSpan.java b/src/main/java/org/opentripplanner/routing/core/TimeRestrictionWithTimeSpan.java new file mode 100644 index 00000000000..81b83960790 --- /dev/null +++ b/src/main/java/org/opentripplanner/routing/core/TimeRestrictionWithTimeSpan.java @@ -0,0 +1,61 @@ +package org.opentripplanner.routing.core; + +import java.time.LocalDateTime; +import java.util.Optional; +import lombok.Data; + +/** + * A {@link TimeRestriction} which has a time span. This may be used when a time restriction must be + * true for a longer period of time. For example when using when parking a car, the VehicleParking + * must be open from entering until exiting. + */ +@Data(staticConstructor = "of") +public class TimeRestrictionWithTimeSpan implements TimeRestriction { + + private final TimeRestriction timeRestriction; + private final int spanInSeconds; + + @Override + public boolean isTraverseableAt(LocalDateTime now) { + return timeRestriction.isTraverseableAt(now) + && timeRestriction.isTraverseableAt(now.plusSeconds(spanInSeconds)); + } + + @Override + public Optional earliestDepartureTime(LocalDateTime now) { + var time = now; + do { + var next = timeRestriction.earliestDepartureTime(time); + if (next.isEmpty()) { + return Optional.empty(); + } + + time = next.get(); + var timeAtEnd = time.plusSeconds(spanInSeconds); + if (timeRestriction.isTraverseableAt(timeAtEnd)) { + return next; + } else { + time = timeAtEnd; + } + } while (true); + } + + @Override + public Optional latestArrivalTime(LocalDateTime now) { + var time = now; + do { + var previous = timeRestriction.latestArrivalTime(time); + if (previous.isEmpty()) { + return Optional.empty(); + } + + time = previous.get(); + var timeAtEnd = time.plusSeconds(spanInSeconds); + if (timeRestriction.isTraverseableAt(timeAtEnd)) { + return previous; + } else { + time = time.minusSeconds(spanInSeconds); + } + } while (true); + } +} diff --git a/src/main/java/org/opentripplanner/routing/edgetype/TimeRestrictedEdge.java b/src/main/java/org/opentripplanner/routing/edgetype/TimeRestrictedEdge.java new file mode 100644 index 00000000000..b041405f8e4 --- /dev/null +++ b/src/main/java/org/opentripplanner/routing/edgetype/TimeRestrictedEdge.java @@ -0,0 +1,82 @@ +package org.opentripplanner.routing.edgetype; + +import java.time.Duration; +import java.time.LocalDateTime; +import org.opentripplanner.routing.core.State; +import org.opentripplanner.routing.core.StateEditor; +import org.opentripplanner.routing.core.TimeRestriction; +import org.opentripplanner.routing.core.TimeRestrictionWithOffset; + +public interface TimeRestrictedEdge { + + default boolean isTraversalBlockedByTimeRestriction(State s0, boolean allowWaiting, TimeRestriction timeRestriction) { + final var options = s0.getOptions(); + if (options.ignoreAndCollectTimeRestrictions || timeRestriction == null) { + return false; + } + + return isTraversalBlockedByTimeRestriction(s0, s0.getTimeAsLocalDateTime(), allowWaiting, timeRestriction); + } + + default boolean isTraversalBlockedByTimeRestriction(State s0, LocalDateTime now, boolean allowWaiting, TimeRestriction timeRestriction) { + final var options = s0.getOptions(); + if (options.ignoreAndCollectTimeRestrictions) { + return false; + } + else { + if (allowWaiting) { + var altTime = options.arriveBy + ? timeRestriction.latestArrivalTime(now) + : timeRestriction.earliestDepartureTime(now); + return altTime.isEmpty(); + } + else { + return !timeRestriction.isTraverseableAt(now); + } + } + } + + /** + * Add a TimeRestriction using the {@link StateEditor#getElapsedTimeSeconds()} as the offset. + */ + default void updateEditorWithTimeRestriction( + State s0, + StateEditor s1, + TimeRestriction timeRestriction, + Object source + ) { + updateEditorWithTimeRestriction(s0, s1, s1.getElapsedTimeSeconds(), timeRestriction, source); + } + + default void updateEditorWithTimeRestriction( + State s0, + StateEditor s1, + long offset, + TimeRestriction timeRestriction, + Object source + ) { + if (timeRestriction == null) { + return; + } + + if (s0.getOptions().ignoreAndCollectTimeRestrictions) { + s1.addTimeRestriction(TimeRestrictionWithOffset.of(timeRestriction, offset), source); + } + else { + var zoneId = s0.getOptions().rctx.graph.getTimeZone().toZoneId(); + var now = s1.getZonedDateTime(); + var time = s0.getOptions().arriveBy + ? timeRestriction.latestArrivalTime(now.toLocalDateTime()) + : timeRestriction.earliestDepartureTime(now.toLocalDateTime()); + if (time.isPresent()) { + var waitTime = + (int) Math.abs(Duration.between(now, time.get().atZone(zoneId)).getSeconds()); + s1.incrementWeight(waitTime * s0.getOptions().waitReluctance); + s1.incrementTimeInSeconds(waitTime); + } + else { + throw new IllegalStateException("Missing traversal time!"); + } + } + } +} diff --git a/src/main/java/org/opentripplanner/routing/edgetype/VehicleParkingEdge.java b/src/main/java/org/opentripplanner/routing/edgetype/VehicleParkingEdge.java index 952d9941147..79f80eeb918 100644 --- a/src/main/java/org/opentripplanner/routing/edgetype/VehicleParkingEdge.java +++ b/src/main/java/org/opentripplanner/routing/edgetype/VehicleParkingEdge.java @@ -5,6 +5,8 @@ import org.opentripplanner.routing.api.request.RoutingRequest; import org.opentripplanner.routing.core.State; import org.opentripplanner.routing.core.StateEditor; +import org.opentripplanner.routing.core.TimeRestriction; +import org.opentripplanner.routing.core.TimeRestrictionWithTimeSpan; import org.opentripplanner.routing.core.TraverseMode; import org.opentripplanner.routing.graph.Edge; import org.opentripplanner.routing.vehicle_parking.VehicleParking; @@ -13,7 +15,7 @@ /** * Parking a vehicle edge. */ -public class VehicleParkingEdge extends Edge { +public class VehicleParkingEdge extends Edge implements TimeRestrictedEdge { private static final long serialVersionUID = 1L; @@ -32,6 +34,18 @@ public VehicleParking getVehicleParking() { return vehicleParking; } + private TimeRestriction getTimeRestriction(int parkingTime) { + var openingHours = vehicleParking.getOpeningHours(); + if (openingHours == null) { + return null; + } + + return TimeRestrictionWithTimeSpan.of( + vehicleParking.getOpeningHours(), + parkingTime + ); + } + @Override public State traverse(State s0) { RoutingRequest options = s0.getOptions(); @@ -69,10 +83,19 @@ private State traverseUnPark(State s0, int parkingCost, int parkingTime, Travers return null; } + var timeRestriction = getTimeRestriction(parkingTime); + if (isTraversalBlockedByTimeRestriction(s0, true, timeRestriction)) { + return null; + } + StateEditor s0e = s0.edit(this); + s0e.incrementWeight(parkingCost); s0e.incrementTimeInSeconds(parkingTime); s0e.setVehicleParked(false, mode); + + updateEditorWithTimeRestriction(s0, s0e, timeRestriction, getVehicleParking()); + return s0e.makeState(); } @@ -104,7 +127,15 @@ private State traversePark(State s0, int parkingCost, int parkingTime) { return null; } + var timeRestriction = getTimeRestriction(parkingTime); + if (isTraversalBlockedByTimeRestriction(s0, false, timeRestriction)) { + return null; + } + StateEditor s0e = s0.edit(this); + + updateEditorWithTimeRestriction(s0, s0e, timeRestriction, getVehicleParking()); + s0e.incrementWeight(parkingCost); s0e.incrementTimeInSeconds(parkingTime); s0e.setVehicleParked(true, TraverseMode.WALK); @@ -136,14 +167,7 @@ public boolean hasBogusName() { return false; } - public boolean equals(Object o) { - if (o instanceof VehicleParkingEdge) { - VehicleParkingEdge other = (VehicleParkingEdge) o; - return other.getFromVertex().equals(fromv) && other.getToVertex().equals(tov); - } - return false; - } - + @Override public String toString() { return "VehicleParkingEdge(" + fromv + " -> " + tov + ")"; } diff --git a/src/main/java/org/opentripplanner/routing/spt/DominanceFunction.java b/src/main/java/org/opentripplanner/routing/spt/DominanceFunction.java index 7a305236c6b..07386820ae4 100644 --- a/src/main/java/org/opentripplanner/routing/spt/DominanceFunction.java +++ b/src/main/java/org/opentripplanner/routing/spt/DominanceFunction.java @@ -66,6 +66,18 @@ public boolean betterOrEqualAndComparable(State a, State b) { return false; } + /* + * When creating AccessEgress paths for RAPTOR states with have different time restrictions + * have to be considered, since a non-optimal VehicleParking could be used after the optimal + * VehicleParking is closed. + * + * Simply using the time restrictions isn't enough, since simply re-traversing edges in a + * loop would create new states. + */ + if (!Objects.equals(a.getTimeRestrictionSources(), b.getTimeRestrictionSources())) { + return false; + } + /* * The OTP algorithm tries hard to never visit the same node twice. This is generally a good idea because it avoids * useless loops in the traversal leading to way faster processing time. diff --git a/src/main/java/org/opentripplanner/routing/vehicle_parking/VehicleParking.java b/src/main/java/org/opentripplanner/routing/vehicle_parking/VehicleParking.java index 7bc4e8cf3fb..2f1d61d655d 100644 --- a/src/main/java/org/opentripplanner/routing/vehicle_parking/VehicleParking.java +++ b/src/main/java/org/opentripplanner/routing/vehicle_parking/VehicleParking.java @@ -9,6 +9,7 @@ import java.util.Objects; import java.util.Set; import org.opentripplanner.model.FeedScopedId; +import org.opentripplanner.routing.core.TimeRestriction; import org.opentripplanner.routing.core.TraverseMode; import org.opentripplanner.util.I18NString; @@ -51,6 +52,18 @@ public class VehicleParking implements Serializable { */ private final Set tags; + /** + * The opening hours of this vehicle parking, when it is possible to drop off / pickup a vehicle. + * May be {@code null}. + */ + private final TimeRestriction openingHours; + + /** + * The fee hours of this vehicle parking, when a fee is required for drop off / pickup. + * May be {@code null}. + */ + private final TimeRestriction feeHours; + /** * A short translatable note containing details of this vehicle parking. */ @@ -99,6 +112,8 @@ public class VehicleParking implements Serializable { String detailsUrl, String imageUrl, Set tags, + TimeRestriction openingHours, + TimeRestriction feeHours, I18NString note, VehicleParkingState state, boolean bicyclePlaces, @@ -114,6 +129,8 @@ public class VehicleParking implements Serializable { this.detailsUrl = detailsUrl; this.imageUrl = imageUrl; this.tags = tags; + this.openingHours = openingHours; + this.feeHours = feeHours; this.note = note; this.state = state; this.bicyclePlaces = bicyclePlaces; @@ -151,6 +168,14 @@ public Set getTags() { return tags; } + public TimeRestriction getOpeningHours() { + return openingHours; + } + + public TimeRestriction getFeeHours() { + return feeHours; + } + public I18NString getNote() { return note; } @@ -300,6 +325,8 @@ public static class VehicleParkingBuilder { private double y; private String detailsUrl; private String imageUrl; + private TimeRestriction openingHours; + private TimeRestriction feeHours; private I18NString note; private VehicleParkingState state$value; private boolean state$set; @@ -356,6 +383,16 @@ public VehicleParkingBuilder imageUrl(String imageUrl) { return this; } + public VehicleParkingBuilder openingHours(TimeRestriction openingHours) { + this.openingHours = openingHours; + return this; + } + + public VehicleParkingBuilder feeHours(TimeRestriction feeHours) { + this.feeHours = feeHours; + return this; + } + public VehicleParkingBuilder note(I18NString note) { this.note = note; return this; @@ -399,7 +436,7 @@ public VehicleParking build() { } var vehicleParking = new VehicleParking( - id, name, x, y, detailsUrl, imageUrl, tags, note, state$value, + id, name, x, y, detailsUrl, imageUrl, tags, openingHours, feeHours, note, state$value, bicyclePlaces, carPlaces, wheelchairAccessibleCarPlaces, capacity, availability ); this.entranceCreators.forEach(vehicleParking::addEntrance); diff --git a/src/main/java/org/opentripplanner/standalone/config/RoutingRequestMapper.java b/src/main/java/org/opentripplanner/standalone/config/RoutingRequestMapper.java index 6a7b0d72df8..62b212058f7 100644 --- a/src/main/java/org/opentripplanner/standalone/config/RoutingRequestMapper.java +++ b/src/main/java/org/opentripplanner/standalone/config/RoutingRequestMapper.java @@ -92,6 +92,7 @@ public static RoutingRequest mapRoutingRequest(NodeAdapter c) { request.useVehicleRentalAvailabilityInformation = c.asBoolean("useBikeRentalAvailabilityInformation", dft.useVehicleRentalAvailabilityInformation); request.useVehicleParkingAvailabilityInformation = c.asBoolean("useVehicleParkingAvailabilityInformation", dft.useVehicleParkingAvailabilityInformation); request.useUnpreferredRoutesPenalty = c.asInt("useUnpreferredRoutesPenalty", dft.useUnpreferredRoutesPenalty); + request.vehicleParkingClosesSoonSeconds = c.asInt("vehicleParkingClosesSoonSeconds", dft.vehicleParkingClosesSoonSeconds); request.vehicleRental = c.asBoolean("allowBikeRental", dft.vehicleRental); request.waitAtBeginningFactor = c.asDouble("waitAtBeginningFactor", dft.waitAtBeginningFactor); request.waitReluctance = c.asDouble("waitReluctance", dft.waitReluctance); diff --git a/src/test/java/org/opentripplanner/routing/algorithm/CarParkAndRideTest.java b/src/test/java/org/opentripplanner/routing/algorithm/CarParkAndRideTest.java index fdb256f0efc..50b9724dff7 100644 --- a/src/test/java/org/opentripplanner/routing/algorithm/CarParkAndRideTest.java +++ b/src/test/java/org/opentripplanner/routing/algorithm/CarParkAndRideTest.java @@ -52,7 +52,7 @@ public void build() { ) ); - vehicleParking("CarPark #2", 47.530, 19.001, false, true, true, + vehicleParking("CarPark #2", 47.530, 19.001, false, true, true, null, List.of( vehicleParkingEntrance(D, "CarPark #2 Entrance", true, true) ) diff --git a/src/test/java/org/opentripplanner/routing/algorithm/GraphRoutingTest.java b/src/test/java/org/opentripplanner/routing/algorithm/GraphRoutingTest.java index d21e2194e7d..ed915dc26c9 100644 --- a/src/test/java/org/opentripplanner/routing/algorithm/GraphRoutingTest.java +++ b/src/test/java/org/opentripplanner/routing/algorithm/GraphRoutingTest.java @@ -22,6 +22,7 @@ import org.opentripplanner.model.WheelChairBoarding; import org.opentripplanner.routing.algorithm.astar.AStar; import org.opentripplanner.routing.api.request.RoutingRequest; +import org.opentripplanner.routing.core.TimeRestriction; import org.opentripplanner.routing.core.TraverseMode; import org.opentripplanner.routing.core.TraverseModeSet; import org.opentripplanner.routing.edgetype.ElevatorAlightEdge; @@ -42,6 +43,7 @@ import org.opentripplanner.routing.vehicle_rental.VehicleRentalPlace; import org.opentripplanner.routing.graph.Edge; import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.graph.GraphIndex; import org.opentripplanner.routing.graph.Vertex; import org.opentripplanner.routing.location.TemporaryStreetLocation; import org.opentripplanner.routing.spt.GraphPath; @@ -358,10 +360,14 @@ public List biLink(StreetVertex from, VehicleRentalStat } public VehicleParking vehicleParking(String id, double x, double y, boolean bicyclePlaces, boolean carPlaces, List entrances, String ... tags) { - return vehicleParking(id, x, y, bicyclePlaces, carPlaces, false, entrances, tags); + return vehicleParking(id, x, y, bicyclePlaces, carPlaces, false, null, entrances, tags); } - public VehicleParking vehicleParking(String id, double x, double y, boolean bicyclePlaces, boolean carPlaces, boolean wheelchairAccessibleCarPlaces, List entrances, String ... tags) { + public VehicleParking vehicleParking(String id, double x, double y, boolean bicyclePlaces, boolean carPlaces, TimeRestriction openingHours, List entrances, String ... tags) { + return vehicleParking(id, x, y, bicyclePlaces, carPlaces, false, openingHours, entrances); + } + + public VehicleParking vehicleParking(String id, double x, double y, boolean bicyclePlaces, boolean carPlaces, boolean wheelchairAccessibleCarPlaces, TimeRestriction openingHours, List entrances, String ... tags) { var vehicleParking = VehicleParking.builder() .id(new FeedScopedId(TEST_FEED_ID, id)) .x(x) @@ -369,6 +375,7 @@ public VehicleParking vehicleParking(String id, double x, double y, boolean bicy .bicyclePlaces(bicyclePlaces) .carPlaces(carPlaces) .entrances(entrances) + .openingHours(openingHours) .wheelchairAccessibleCarPlaces(wheelchairAccessibleCarPlaces) .tags(List.of(tags)) .build(); diff --git a/src/test/java/org/opentripplanner/routing/algorithm/ParkAndRideOpeningHoursTest.java b/src/test/java/org/opentripplanner/routing/algorithm/ParkAndRideOpeningHoursTest.java new file mode 100644 index 00000000000..27496787e24 --- /dev/null +++ b/src/test/java/org/opentripplanner/routing/algorithm/ParkAndRideOpeningHoursTest.java @@ -0,0 +1,127 @@ +package org.opentripplanner.routing.algorithm; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.opentripplanner.routing.algorithm.astar.AStar; +import org.opentripplanner.routing.algorithm.raptor.router.street.AccessEgressRouter; +import org.opentripplanner.routing.algorithm.raptor.transit.TransitTuningParameters; +import org.opentripplanner.routing.algorithm.raptor.transit.mappers.AccessEgressMapper; +import org.opentripplanner.routing.algorithm.raptor.transit.mappers.TransitLayerMapper; +import org.opentripplanner.routing.api.request.RoutingRequest; +import org.opentripplanner.routing.api.request.StreetMode; +import org.opentripplanner.routing.core.OsmOpeningHours; +import org.opentripplanner.routing.core.TimeRestriction; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.vertextype.StreetVertex; +import org.opentripplanner.routing.vertextype.TransitStopVertex; + +public class ParkAndRideOpeningHoursTest extends GraphRoutingTest { + + // A thursday + private static final ZonedDateTime START_OF_TIME = ZonedDateTime.of(2021, 5, 20, 12, 0, 0, 0, ZoneId.of("GMT")); + private static final int CAR_PARK_TIME = 180; + + private Graph graph; + private StreetVertex A; + private TransitStopVertex S; + + public void createGraph(TimeRestriction openingHours) { + graph = graphOf(new Builder() { + @Override + public void build() { + A = intersection("A", 47.500, 19.000); + + S = stop("S1", 47.500, 18.999); + + biLink(A, S); + + vehicleParking("CarPark #1", 47.500, 19.001, false, true, + openingHours, + List.of(vehicleParkingEntrance(A, "CarPark #1 Entrance A", true, true)) + ); + } + }); + + graph.index(); + + graph.setTransitLayer(TransitLayerMapper.map(TransitTuningParameters.FOR_TEST, graph)); + } + + @Test + public void testVehicleParkingAlwaysOpen() throws Exception { + createGraph(OsmOpeningHours.parseFromOsm("24/7")); + assertParkAndRideAccess(0, 0); + assertParkAndRideTraversal(CAR_PARK_TIME, -CAR_PARK_TIME); + } + + @Test + public void testVehicleParkingPartiallyOpenForward() throws Exception { + createGraph(OsmOpeningHours.parseFromOsm("Mo-Su 09:00-12:00;Mo-Su 13:00-15:00")); + assertParkAndRideAccess(60 * 60, 0); + assertParkAndRideTraversal(null, -CAR_PARK_TIME); + } + + @Test + public void testVehicleParkingPartiallyOpenReverse() throws Exception { + createGraph(OsmOpeningHours.parseFromOsm("Mo-Su 09:00-11:00,12:00-15:00")); + assertParkAndRideAccess(0, -60 * 60); + assertParkAndRideTraversal(CAR_PARK_TIME, -(60 * 60 + CAR_PARK_TIME)); + } + + @Test + public void testVehicleParkingClosed() throws Exception { + createGraph(OsmOpeningHours.parseFromOsm("Mo-Su 09:00-11:00,14:00-16:00")); + assertParkAndRideAccess(2 * 60 * 60, -1 * 60 * 60); + assertParkAndRideTraversal(null, -(60 * 60 + CAR_PARK_TIME)); + } + + private void assertParkAndRideAccess(int earliestDepartureTime, int latestArrivalTime) { + var rr = new RoutingRequest().getStreetSearchRequest(StreetMode.CAR_TO_PARK); + rr.carParkTime = 60; + rr.setRoutingContext(graph, A, null); + + var stops = AccessEgressRouter.streetSearch(rr, StreetMode.CAR_TO_PARK, false); + assertEquals(1, stops.size(), "nearby access stops"); + + var accessEgress = + new AccessEgressMapper(graph.getTransitLayer().getStopIndex()) + .mapNearbyStop(stops.iterator().next(), START_OF_TIME, false); + + assertEquals(earliestDepartureTime, accessEgress.earliestDepartureTime(0), "access earliestDepartureTime"); + assertEquals(latestArrivalTime, accessEgress.latestArrivalTime(0), "access latestArrivalTime"); + } + + private void assertParkAndRideTraversal(Integer departAtDuration, Integer arriveByDuration) { + assertEquals(departAtDuration, parkAndRideDuration(false), "departAt duration"); + assertEquals(arriveByDuration, parkAndRideDuration(true), "arriveBy duration"); + } + + private Integer parkAndRideDuration(boolean arriveBy) { + var options = new RoutingRequest().getStreetSearchRequest(StreetMode.CAR_TO_PARK); + options.setDateTime(START_OF_TIME.toInstant()); + options.carParkTime = CAR_PARK_TIME; + options.arriveBy = arriveBy; + options.setRoutingContext(graph, A, S); + + var tree = new AStar().getShortestPathTree(options); + var path = tree.getPath( + arriveBy ? A : S, + false + ); + + if (path == null) { + return null; + } + + return (int) Duration.between( + START_OF_TIME, + Instant.ofEpochSecond(options.arriveBy ? path.getStartTime() : path.getEndTime()).atZone(START_OF_TIME.getZone()) + ).getSeconds(); + } +} diff --git a/src/test/java/org/opentripplanner/routing/core/TimeRestrictionWithTimeSpanTest.java b/src/test/java/org/opentripplanner/routing/core/TimeRestrictionWithTimeSpanTest.java new file mode 100644 index 00000000000..89faca8fbd7 --- /dev/null +++ b/src/test/java/org/opentripplanner/routing/core/TimeRestrictionWithTimeSpanTest.java @@ -0,0 +1,54 @@ +package org.opentripplanner.routing.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.poole.openinghoursparser.OpeningHoursParseException; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +class TimeRestrictionWithTimeSpanTest { + + @Test + public void testAlwaysOpen() throws OpeningHoursParseException { + var testee = TimeRestrictionWithTimeSpan.of(OsmOpeningHours.parseFromOsm("24/7"), 60); + var time = LocalDateTime.of(2021, 5, 20, 12, 0, 0); + assertTrue(testee.isTraverseableAt(time)); + assertEquals(time, testee.earliestDepartureTime(time).get()); + assertEquals(time, testee.latestArrivalTime(time).get()); + } + + @Test + public void testClosedAtStart() throws OpeningHoursParseException { + var testee = TimeRestrictionWithTimeSpan.of(OsmOpeningHours.parseFromOsm("Mo-Su 12:01-14:00"), 120); + var time = LocalDateTime.of(2021, 5, 20, 12, 0, 0); + var edt = LocalDateTime.of(2021, 5, 20, 12, 1, 0); + var lat = LocalDateTime.of(2021, 5, 19, 13, 58, 0); + assertFalse(testee.isTraverseableAt(time)); + assertEquals(edt, testee.earliestDepartureTime(time).get()); + assertEquals(lat, testee.latestArrivalTime(time).get()); + } + + @Test + public void testClosedAtEnd() throws OpeningHoursParseException { + var testee = TimeRestrictionWithTimeSpan.of(OsmOpeningHours.parseFromOsm("Mo-Su 10:00-12:00"), 60); + var time = LocalDateTime.of(2021, 5, 20, 12, 0, 0); + var edt = LocalDateTime.of(2021, 5, 21, 10, 0, 0); + var lat = LocalDateTime.of(2021, 5, 20, 11, 59, 0); + assertFalse(testee.isTraverseableAt(time)); + assertEquals(edt, testee.earliestDepartureTime(time).get()); + assertEquals(lat, testee.latestArrivalTime(time).get()); + } + + @Test + public void testClosed() throws OpeningHoursParseException { + var testee = TimeRestrictionWithTimeSpan.of(OsmOpeningHours.parseFromOsm("Mo-Su 11:00-13:00"), 60); + var time = LocalDateTime.of(2021, 5, 20, 10, 0, 0); + var edt = LocalDateTime.of(2021, 5, 20, 11, 0, 0); + var lat = LocalDateTime.of(2021, 5, 19, 12, 59, 0); + assertFalse(testee.isTraverseableAt(time)); + assertEquals(edt, testee.earliestDepartureTime(time).get()); + assertEquals(lat, testee.latestArrivalTime(time).get()); + } +} \ No newline at end of file diff --git a/src/test/java/org/opentripplanner/routing/edgetype/TimeRestrictedEdgeTest.java b/src/test/java/org/opentripplanner/routing/edgetype/TimeRestrictedEdgeTest.java new file mode 100644 index 00000000000..752be5e75ac --- /dev/null +++ b/src/test/java/org/opentripplanner/routing/edgetype/TimeRestrictedEdgeTest.java @@ -0,0 +1,286 @@ +package org.opentripplanner.routing.edgetype; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import ch.poole.openinghoursparser.OpeningHoursParseException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.Test; +import org.opentripplanner.routing.algorithm.GraphRoutingTest; +import org.opentripplanner.routing.algorithm.astar.AStar; +import org.opentripplanner.routing.algorithm.raptor.router.street.AccessEgressRouter; +import org.opentripplanner.routing.algorithm.raptor.transit.TransitTuningParameters; +import org.opentripplanner.routing.algorithm.raptor.transit.mappers.AccessEgressMapper; +import org.opentripplanner.routing.algorithm.raptor.transit.mappers.TransitLayerMapper; +import org.opentripplanner.routing.api.request.RoutingRequest; +import org.opentripplanner.routing.api.request.StreetMode; +import org.opentripplanner.routing.core.OsmOpeningHours; +import org.opentripplanner.routing.core.State; +import org.opentripplanner.routing.core.StateEditor; +import org.opentripplanner.routing.core.TimeRestriction; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.graph.Vertex; +import org.opentripplanner.routing.vertextype.StreetVertex; +import org.opentripplanner.routing.vertextype.TransitStopVertex; + +/** + * This tests the four ways time restrictions may be used on edges: 1. normal departAt searches 2. + * normal arriveBy searches 3. access departAt searches 4. egress arriveBy searches + */ +public class TimeRestrictedEdgeTest extends GraphRoutingTest { + + private static final int TRAVERSAL_TIME = 180; + + // A thursday + private static final ZonedDateTime START_OF_TIME = + ZonedDateTime.of(2021, 5, 20, 12, 0, 0, 0, ZoneId.of("GMT")); + + private Graph graph; + private StreetVertex A, B, C; + private TransitStopVertex S; + + public void createGraph(TimeRestriction a_b, TimeRestriction b_c) { + graph = graphOf(new Builder() { + @Override + public void build() { + A = intersection("A", 47.500, 19.000); + B = intersection("B", 47.510, 19.000); + C = intersection("C", 47.520, 19.000); + + S = stop("S", 47.520, 19.001); + + biLink(C, S); + + new TimeRestrictedTestEdge(A, B, 60, a_b, true, true); + new TimeRestrictedTestEdge(B, C, 120, b_c, false, false); + + new TimeRestrictedTestEdge(B, A, 60, a_b, false, true); + new TimeRestrictedTestEdge(C, B, 120, b_c, true, false); + } + }); + + graph.index(); + + graph.setTransitLayer(TransitLayerMapper.map(TransitTuningParameters.FOR_TEST, graph)); + } + + @Test + public void testNoRestrictions() { + createGraph(null, null); + assertTraversal(TRAVERSAL_TIME, -TRAVERSAL_TIME); + assertAccessAndEgress(0, 0); + } + + @Test + public void testAlwaysOpen() throws OpeningHoursParseException { + createGraph(OsmOpeningHours.parseFromOsm("24/7"), OsmOpeningHours.parseFromOsm("24/7")); + assertTraversal(TRAVERSAL_TIME, -TRAVERSAL_TIME); + assertAccessAndEgress(0, 0); + } + + @Test + public void testOnlyFirstAndClosed() throws OpeningHoursParseException { + createGraph(OsmOpeningHours.parseFromOsm("Mo-Su 10:00-11:00,14:00-16:00"), null); + assertTraversal( + // There is a two-hour wait at the beginning, along with traversing all the edges + 2 * 60 * 60 + TRAVERSAL_TIME, + // Since the wait is at the end of the search, there is no extra time for traversal + -60 * 60 + ); + assertAccessAndEgress(2 * 60 * 60, -(60 * 60) + TRAVERSAL_TIME); + } + + @Test + public void testOnlySecondAndFullyClosed() throws OpeningHoursParseException { + createGraph(null, OsmOpeningHours.parseFromOsm("Mo-Su 10:00-11:00,14:00-16:00")); + // It is not possible to traverse, since waiting at the end is not allowed + assertTraversal( + null, + null + ); + assertAccessAndEgress(2 * 60 * 60 - TRAVERSAL_TIME, -60 * 60); + } + + @Test + public void testOnlySecondAndPartiallyClosed() throws OpeningHoursParseException { + createGraph(null, OsmOpeningHours.parseFromOsm("Mo-Su 10:00-12:00,14:00-16:00")); + assertTraversal( + // It is not possible to traverse, since waiting at the end is not allowed + null, + -TRAVERSAL_TIME + ); + assertAccessAndEgress(2 * 60 * 60 - TRAVERSAL_TIME, 0); + } + + @Test + public void testBothFirstClosedSecondOpen() throws OpeningHoursParseException { + createGraph( + OsmOpeningHours.parseFromOsm("Mo-Su 10:00-11:00,14:00-16:00"), + OsmOpeningHours.parseFromOsm("Mo-Su 10:00-16:00") + ); + assertTraversal(2 * 60 * 60 + TRAVERSAL_TIME, -60 * 60); + assertAccessAndEgress(2 * 60 * 60, -60 * 60 + 180); + } + + @Test + public void testBothFirstOpenSecondClosed() throws OpeningHoursParseException { + createGraph( + OsmOpeningHours.parseFromOsm("Mo-Su 10:00-16:00"), + OsmOpeningHours.parseFromOsm("Mo-Su 10:00-11:00,14:00-16:00") + ); + assertTraversal(null, null); + assertAccessAndEgress(2 * 60 * 60 - 180, -60 * 60); + } + + @Test + public void testBothClosedWithWait() throws OpeningHoursParseException { + createGraph( + OsmOpeningHours.parseFromOsm("Mo-Su 10:00-11:00,13:00-14:00"), + OsmOpeningHours.parseFromOsm("Mo-Su 13:00-14:00") + ); + assertTraversal(60 * 60 + TRAVERSAL_TIME, null); + assertAccessAndEgress(60 * 60, -22 * 60 * 60); + } + + @Test + public void testBothClosed() throws OpeningHoursParseException { + createGraph( + OsmOpeningHours.parseFromOsm("Mo-Su 9:00-11:00"), + OsmOpeningHours.parseFromOsm("Mo-Su 10:00-11:00") + ); + assertTraversal(null, null); + assertAccessAndEgress( + // The first time both restrictions may be traversed is the next day at 09:57 + 21 * 60 * 60 + 57 * 60, + -60 * 60 + ); + } + + private void assertTraversal(Integer departAtDuration, Integer arriveByDuration) { + assertEquals(departAtDuration, traversalDuration(false), "departAt duration"); + assertEquals(arriveByDuration, traversalDuration(true), "arriveBy duration"); + } + + private void assertAccessAndEgress(int earliestDepartureTime, int latestArrivalTime) { + assertAccess(earliestDepartureTime, latestArrivalTime); + assertEgress(earliestDepartureTime, latestArrivalTime); + } + + private Integer traversalDuration(boolean arriveBy) { + var options = new RoutingRequest().getStreetSearchRequest(StreetMode.WALK); + options.setDateTime(START_OF_TIME.toInstant()); + options.arriveBy = arriveBy; + options.setRoutingContext(graph, A, C); + + var tree = new AStar().getShortestPathTree(options); + var path = tree.getPath( + arriveBy ? A : C, + false + ); + + if (path == null) { + return null; + } + + return (int) Duration.between( + START_OF_TIME, + Instant.ofEpochSecond(options.arriveBy ? path.getStartTime() : path.getEndTime()) + .atZone(START_OF_TIME.getZone()) + ).getSeconds(); + } + + private void assertAccess(int earliestDepartureTime, int latestArrivalTime) { + var rr = new RoutingRequest().getStreetSearchRequest(StreetMode.WALK); + rr.setRoutingContext(graph, A, null); + + var stops = AccessEgressRouter.streetSearch(rr, StreetMode.WALK, false); + assertEquals(1, stops.size(), "nearby access stops"); + + var accessEgress = + new AccessEgressMapper(graph.getTransitLayer().getStopIndex()) + .mapNearbyStop(stops.iterator().next(), START_OF_TIME, false); + + assertEquals( + earliestDepartureTime, + accessEgress.earliestDepartureTime(0), + "access earliestDepartureTime" + ); + assertEquals( + latestArrivalTime, + accessEgress.latestArrivalTime(0), + "access latestArrivalTime" + ); + } + + private void assertEgress(int earliestDepartureTime, int latestArrivalTime) { + var rr = new RoutingRequest().getStreetSearchRequest(StreetMode.WALK); + rr.setRoutingContext(graph, null, A); + + var stops = AccessEgressRouter.streetSearch(rr, StreetMode.WALK, true); + assertEquals(1, stops.size(), "nearby egress stops"); + + var accessEgress = + new AccessEgressMapper(graph.getTransitLayer().getStopIndex()) + .mapNearbyStop(stops.iterator().next(), START_OF_TIME, true); + + assertEquals( + earliestDepartureTime, + accessEgress.earliestDepartureTime(0), + "egress earliestDepartureTime" + ); + assertEquals( + latestArrivalTime, + accessEgress.latestArrivalTime(0), + "egress latestArrivalTime" + ); + } + + private static class TimeRestrictedTestEdge extends FreeEdge implements TimeRestrictedEdge { + + private final int traversalTime; + private final TimeRestriction timeRestriction; + private final boolean restrictionAtFrom; + private final boolean allowWaiting; + + public TimeRestrictedTestEdge( + Vertex from, + Vertex to, + int traversalTime, + TimeRestriction timeRestriction, + boolean restrictionAtFrom, + boolean allowWaiting + ) { + super(from, to); + this.traversalTime = traversalTime; + this.timeRestriction = timeRestriction; + this.restrictionAtFrom = restrictionAtFrom; + this.allowWaiting = allowWaiting; + } + + @Override + public State traverse(State s0) { + if (isTraversalBlockedByTimeRestriction(s0, allowWaiting, timeRestriction)) { + return null; + } + + StateEditor s1 = s0.edit(this); + s1.incrementWeight(1); + + var checkThenTraverse = s0.getOptions().arriveBy != restrictionAtFrom; + if (checkThenTraverse) { + // The edge must open when entering, or the restriction has a time span + updateEditorWithTimeRestriction(s0, s1, timeRestriction, fromv); + s1.incrementTimeInSeconds(traversalTime); + } + else { + // The edge must open when exiting + s1.incrementTimeInSeconds(traversalTime); + updateEditorWithTimeRestriction(s0, s1, timeRestriction, tov); + } + + return s1.makeState(); + } + } +}