diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/router/TransitRouter.java b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/router/TransitRouter.java index 9f0a1f3db8d..905dc0d4083 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/router/TransitRouter.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/router/TransitRouter.java @@ -32,6 +32,7 @@ import org.opentripplanner.routing.algorithm.raptoradapter.transit.RoutingAccessEgress; import org.opentripplanner.routing.algorithm.raptoradapter.transit.TripSchedule; import org.opentripplanner.routing.algorithm.raptoradapter.transit.mappers.AccessEgressMapper; +import org.opentripplanner.routing.algorithm.raptoradapter.transit.mappers.DirectTransitRequestMapper; import org.opentripplanner.routing.algorithm.raptoradapter.transit.mappers.RaptorRequestMapper; import org.opentripplanner.routing.algorithm.raptoradapter.transit.request.DefaultTransitDataProviderFilter; import org.opentripplanner.routing.algorithm.raptoradapter.transit.request.RaptorRoutingRequestTransitData; @@ -136,7 +137,8 @@ private TransitRouterResult route() { ); // Prepare transit search - var raptorRequest = RaptorRequestMapper.mapRequest( + + var mapper = RaptorRequestMapper.of( request, transitSearchTimeZero, serverContext.raptorConfig().isMultiThreaded(), @@ -147,8 +149,9 @@ private TransitRouterResult route() { this::listStopIndexes, linkingContext ); + var raptorRequest = mapper.mapRaptorRequest(); - // Route transit + // Transit routing using Raptor var raptorService = new RaptorService<>( serverContext.raptorConfig(), createExtraMcRouterSearch(accessEgresses, raptorTransitData) @@ -157,9 +160,26 @@ private TransitRouterResult route() { checkIfTransitConnectionExists(transitResponse); + Collection> paths = transitResponse.paths(); + debugTimingAggregator.finishedRaptorSearch(); - Collection> paths = transitResponse.paths(); + // Route Direct transit + var directRequest = DirectTransitRequestMapper.map( + request, + transitResponse.requestUsed().searchParams() + ); + if (directRequest.isPresent()) { + debugTimingAggregator.startedDirectTransitSearch(); + var directPaths = raptorService.findAllDirectTransit( + directRequest.get(), + requestTransitDataProvider + ); + paths = new ArrayList<>(paths); + paths.addAll(directPaths); + debugTimingAggregator.finishedDirectTransitSearch(); + } + debugTimingAggregator.startedItineraryCreation(); // TODO VIA - Temporarily turn OptimizeTransfers OFF for VIA search until the service support via // Remove '&& !request.isViaSearch()' @@ -179,7 +199,7 @@ private TransitRouterResult route() { request.preferences().transfer().optimization(), raptorRequest.searchParams().viaLocations() ); - paths = service.optimize(transitResponse.paths()); + paths = service.optimize(paths); } // Create itineraries diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/AccessEgressWithExtraCost.java b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/AccessEgressWithExtraCost.java new file mode 100644 index 00000000000..437ccebc5b1 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/AccessEgressWithExtraCost.java @@ -0,0 +1,22 @@ +package org.opentripplanner.routing.algorithm.raptoradapter.transit; + +import org.opentripplanner.raptor.api.model.AbstractAccessEgressDecorator; +import org.opentripplanner.raptor.api.model.RaptorAccessEgress; + +/** + * This decorator will add an extra cost factor to the c1 of the access/egress. + */ +public class AccessEgressWithExtraCost extends AbstractAccessEgressDecorator { + + private final double costFactor; + + public AccessEgressWithExtraCost(RaptorAccessEgress delegate, double costFactor) { + super(delegate); + this.costFactor = costFactor; + } + + @Override + public int c1() { + return (int) (delegate().c1() * costFactor); + } +} diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/DirectTransitRequestMapper.java b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/DirectTransitRequestMapper.java new file mode 100644 index 00000000000..b9f444331bd --- /dev/null +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/DirectTransitRequestMapper.java @@ -0,0 +1,83 @@ +package org.opentripplanner.routing.algorithm.raptoradapter.transit.mappers; + +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.opentripplanner.raptor.api.model.RaptorAccessEgress; +import org.opentripplanner.raptor.api.request.SearchParams; +import org.opentripplanner.raptor.direct.api.RaptorDirectTransitRequest; +import org.opentripplanner.routing.algorithm.raptoradapter.transit.AccessEgressWithExtraCost; +import org.opentripplanner.routing.api.request.RouteRequest; + +public class DirectTransitRequestMapper { + + /// Map the request into a request object for the direct transit search. Will return empty if + /// the direct transit search shouldn't be run. + public static Optional map( + RouteRequest request, + SearchParams searchParamsUsed + ) { + var directTransitRequestOpt = request.preferences().transit().directTransit(); + if (directTransitRequestOpt.isEmpty()) { + return Optional.empty(); + } + var rel = directTransitRequestOpt.orElseThrow(); + Collection access = searchParamsUsed.accessPaths(); + Collection egress = searchParamsUsed.egressPaths(); + + access = filterAccessEgressNoOpeningHours(access); + egress = filterAccessEgressNoOpeningHours(egress); + + if (rel.maxAccessEgressDuration().isPresent()) { + var maxDuration = rel.maxAccessEgressDuration().get(); + access = filterAccessEgressByDuration(access, maxDuration); + egress = filterAccessEgressByDuration(egress, maxDuration); + } + if (rel.isExtraReluctanceAddedToAccessAndEgress()) { + double f = rel.extraAccessEgressReluctance(); + access = decorateAccessEgressWithExtraCost(access, f); + egress = decorateAccessEgressWithExtraCost(egress, f); + } + if (access.isEmpty() || egress.isEmpty()) { + return Optional.empty(); + } + var directRequest = RaptorDirectTransitRequest.of() + .addAccessPaths(access) + .addEgressPaths(egress) + .searchWindowInSeconds(searchParamsUsed.searchWindowInSeconds()) + .earliestDepartureTime(searchParamsUsed.earliestDepartureTime()) + .withRelaxC1(RaptorRequestMapper.mapRelaxCost(rel.costRelaxFunction())) + .build(); + return Optional.of(directRequest); + } + + private static List filterAccessEgressByDuration( + Collection list, + Duration maxDuration + ) { + return list + .stream() + .filter(ae -> ae.durationInSeconds() <= maxDuration.toSeconds()) + .toList(); + } + + private static List filterAccessEgressNoOpeningHours( + Collection list + ) { + return list + .stream() + .filter(it -> !it.hasOpeningHours()) + .toList(); + } + + private static List decorateAccessEgressWithExtraCost( + Collection list, + double costFactor + ) { + return list + .stream() + .map(it -> new AccessEgressWithExtraCost(it, costFactor)) + .toList(); + } +} diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/RaptorRequestMapper.java b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/RaptorRequestMapper.java index abb678cd4aa..7ffe201dfd5 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/RaptorRequestMapper.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/RaptorRequestMapper.java @@ -76,7 +76,7 @@ private RaptorRequestMapper( this.linkingContext = Objects.requireNonNull(linkingContext); } - public static RaptorRequest mapRequest( + public static RaptorRequestMapper of( RouteRequest request, ZonedDateTime transitSearchTimeZero, boolean isMultiThreaded, @@ -97,10 +97,10 @@ public static RaptorRequest mapRequest( viaTransferResolver, lookUpStopIndex, linkingContext - ).doMap(); + ); } - private RaptorRequest doMap() { + public RaptorRequest mapRaptorRequest() { var builder = new RaptorRequestBuilder(); var searchParams = builder.searchParams(); var preferences = request.preferences(); diff --git a/application/src/main/java/org/opentripplanner/routing/api/request/preference/DirectTransitPreferences.java b/application/src/main/java/org/opentripplanner/routing/api/request/preference/DirectTransitPreferences.java new file mode 100644 index 00000000000..c4b086449a1 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/routing/api/request/preference/DirectTransitPreferences.java @@ -0,0 +1,173 @@ +package org.opentripplanner.routing.api.request.preference; + +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import javax.annotation.Nullable; +import org.opentripplanner.framework.model.Cost; +import org.opentripplanner.routing.api.request.framework.CostLinearFunction; +import org.opentripplanner.utils.tostring.ToStringBuilder; + +/// All preferences related to the Direct Transit Search +public class DirectTransitPreferences { + + // The next constants are package-local to be readable in the unit-test. + static final double DEFAULT_RELUCTANCE = 1.0; + static final CostLinearFunction DEFAULT_COST_RELAX_FUNCTION = CostLinearFunction.of( + Cost.costOfMinutes(15), + 1.5 + ); + + public static final DirectTransitPreferences DEFAULT = new DirectTransitPreferences( + false, + DEFAULT_COST_RELAX_FUNCTION, + DEFAULT_RELUCTANCE, + null + ); + + private final boolean enabled; + private final CostLinearFunction costRelaxFunction; + private final double extraAccessEgressReluctance; + + @Nullable + private final Duration maxAccessEgressDuration; + + private DirectTransitPreferences( + boolean enabled, + CostLinearFunction costRelaxFunction, + double extraAccessEgressReluctance, + @Nullable Duration maxAccessEgressDuration + ) { + this.enabled = enabled; + this.costRelaxFunction = Objects.requireNonNull(costRelaxFunction); + this.extraAccessEgressReluctance = extraAccessEgressReluctance; + this.maxAccessEgressDuration = maxAccessEgressDuration; + } + + public static Builder of() { + return new Builder(DEFAULT); + } + + public Builder copyOf() { + return new Builder(this); + } + + /// Whether to enable direct transit search + public boolean enabled() { + return enabled; + } + + /// This is used to limit the results from the search. Paths are compared with the cheapest path + /// in the search window and are included in the result if they fall within the limit given by the + /// costRelaxFunction. + public CostLinearFunction costRelaxFunction() { + return costRelaxFunction; + } + + /// An extra cost that is used to increase the cost of the access/egress legs for this search. + public double extraAccessEgressReluctance() { + return extraAccessEgressReluctance; + } + + /// Whether there is any extra access/egress reluctance + public boolean isExtraReluctanceAddedToAccessAndEgress() { + return extraAccessEgressReluctance != DEFAULT_RELUCTANCE; + } + + /// A limit on the duration for access/egress for this search. Setting this to 0 will only include + /// results that require no access or egress. I.e. a stop-to-stop search. + public Optional maxAccessEgressDuration() { + return Optional.ofNullable(maxAccessEgressDuration); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + DirectTransitPreferences that = (DirectTransitPreferences) o; + return ( + enabled == that.enabled && + Double.compare(extraAccessEgressReluctance, that.extraAccessEgressReluctance) == 0 && + Objects.equals(maxAccessEgressDuration, that.maxAccessEgressDuration) && + Objects.equals(costRelaxFunction, that.costRelaxFunction) + ); + } + + @Override + public int hashCode() { + return Objects.hash( + enabled, + costRelaxFunction, + extraAccessEgressReluctance, + maxAccessEgressDuration + ); + } + + @Override + public String toString() { + if (!enabled) { + return "DirectTransitPreferences{not enabled}"; + } + return ToStringBuilder.of(DirectTransitPreferences.class) + .addObj("costRelaxFunction", costRelaxFunction, DEFAULT.costRelaxFunction) + .addNum( + "extraAccessEgressReluctance", + extraAccessEgressReluctance, + DEFAULT.extraAccessEgressReluctance + ) + .addDuration( + "maxAccessEgressDuration", + maxAccessEgressDuration, + DEFAULT.maxAccessEgressDuration + ) + .toString(); + } + + public static class Builder { + + private boolean enabled; + private CostLinearFunction costRelaxFunction; + private double extraAccessEgressReluctance; + private Duration maxAccessEgressDuration; + public DirectTransitPreferences original; + + public Builder(DirectTransitPreferences original) { + this.original = original; + this.enabled = original.enabled; + this.costRelaxFunction = original.costRelaxFunction; + this.extraAccessEgressReluctance = original.extraAccessEgressReluctance; + this.maxAccessEgressDuration = original.maxAccessEgressDuration; + } + + public Builder withEnabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + public Builder withCostRelaxFunction(CostLinearFunction costRelaxFunction) { + this.costRelaxFunction = costRelaxFunction; + return this; + } + + public Builder withExtraAccessEgressReluctance(double extraAccessEgressReluctance) { + this.extraAccessEgressReluctance = extraAccessEgressReluctance; + return this; + } + + public Builder withMaxAccessEgressDuration(Duration maxAccessEgressDuration) { + this.maxAccessEgressDuration = maxAccessEgressDuration; + return this; + } + + public DirectTransitPreferences build() { + var value = new DirectTransitPreferences( + enabled, + costRelaxFunction, + extraAccessEgressReluctance, + maxAccessEgressDuration + ); + return original.equals(value) ? original : value; + } + } +} diff --git a/application/src/main/java/org/opentripplanner/routing/api/request/preference/TransitPreferences.java b/application/src/main/java/org/opentripplanner/routing/api/request/preference/TransitPreferences.java index bc34435a599..afd23ea5f1a 100644 --- a/application/src/main/java/org/opentripplanner/routing/api/request/preference/TransitPreferences.java +++ b/application/src/main/java/org/opentripplanner/routing/api/request/preference/TransitPreferences.java @@ -5,6 +5,7 @@ import java.io.Serializable; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.function.Consumer; import org.opentripplanner.framework.model.Cost; import org.opentripplanner.routing.api.request.framework.CostLinearFunction; @@ -31,6 +32,7 @@ public final class TransitPreferences implements Serializable { private final boolean includePlannedCancellations; private final boolean includeRealtimeCancellations; private final RaptorPreferences raptor; + private final DirectTransitPreferences directTransitPreferences; private TransitPreferences() { this.boardSlack = this.alightSlack = DurationForEnum.of(TransitMode.class).build(); @@ -42,6 +44,7 @@ private TransitPreferences() { this.includePlannedCancellations = false; this.includeRealtimeCancellations = false; this.raptor = RaptorPreferences.DEFAULT; + this.directTransitPreferences = DirectTransitPreferences.DEFAULT; } private TransitPreferences(Builder builder) { @@ -55,6 +58,7 @@ private TransitPreferences(Builder builder) { this.includePlannedCancellations = builder.includePlannedCancellations; this.includeRealtimeCancellations = builder.includeRealtimeCancellations; this.raptor = requireNonNull(builder.raptor); + this.directTransitPreferences = requireNonNull(builder.directTransitPreferences); } public static Builder of() { @@ -169,6 +173,12 @@ public RaptorPreferences raptor() { return raptor; } + public Optional directTransit() { + return directTransitPreferences.enabled() + ? Optional.of(directTransitPreferences) + : Optional.empty(); + } + @Override public boolean equals(Object o) { if (this == o) { @@ -188,7 +198,8 @@ public boolean equals(Object o) { ignoreRealtimeUpdates == that.ignoreRealtimeUpdates && includePlannedCancellations == that.includePlannedCancellations && includeRealtimeCancellations == that.includeRealtimeCancellations && - raptor.equals(that.raptor) + raptor.equals(that.raptor) && + directTransitPreferences.equals(that.directTransitPreferences) ); } @@ -204,7 +215,8 @@ public int hashCode() { ignoreRealtimeUpdates, includePlannedCancellations, includeRealtimeCancellations, - raptor + raptor, + directTransitPreferences ); } @@ -234,6 +246,11 @@ public String toString() { includeRealtimeCancellations != DEFAULT.includeRealtimeCancellations ) .addObj("raptor", raptor, DEFAULT.raptor) + .addObj( + "directTransitPreferences", + directTransitPreferences, + DEFAULT.directTransitPreferences + ) .toString(); } @@ -252,6 +269,7 @@ public static class Builder { private boolean includePlannedCancellations; private boolean includeRealtimeCancellations; private RaptorPreferences raptor; + private DirectTransitPreferences directTransitPreferences; public Builder(TransitPreferences original) { this.original = original; @@ -265,6 +283,7 @@ public Builder(TransitPreferences original) { this.includePlannedCancellations = original.includePlannedCancellations; this.includeRealtimeCancellations = original.includeRealtimeCancellations; this.raptor = original.raptor; + this.directTransitPreferences = original.directTransitPreferences; } public TransitPreferences original() { @@ -334,6 +353,13 @@ public Builder withRaptor(Consumer body) { return this; } + public Builder withDirectTransitPreferences(Consumer body) { + var builder = directTransitPreferences.copyOf(); + body.accept(builder); + this.directTransitPreferences = builder.build(); + return this; + } + public Builder apply(Consumer body) { body.accept(this); return this; diff --git a/application/src/main/java/org/opentripplanner/routing/framework/DebugTimingAggregator.java b/application/src/main/java/org/opentripplanner/routing/framework/DebugTimingAggregator.java index 95405b69120..3092dc66d0d 100644 --- a/application/src/main/java/org/opentripplanner/routing/framework/DebugTimingAggregator.java +++ b/application/src/main/java/org/opentripplanner/routing/framework/DebugTimingAggregator.java @@ -41,6 +41,7 @@ public class DebugTimingAggregator { private final Timer tripPatternFilterTimer; private final Timer accessEgressTimer; private final Timer raptorSearchTimer; + private final Timer directTransitSearchTimer; private final Timer itineraryCreationTimer; private final Timer transitRouterTimer; private final Timer filteringTimer; @@ -58,11 +59,12 @@ public class DebugTimingAggregator { private long directCarpoolRouterTime; private Timer.Sample finishedPatternFiltering; private Timer.Sample finishedAccessEgress; - private Timer.Sample finishedRaptorSearch; private Timer.Sample finishedRouters; private Timer.Sample finishedFiltering; private Timer.Sample startedAccessCalculating; private Timer.Sample startedEgressCalculating; + private Timer.Sample startedDirectTransitSearch; + private Timer.Sample startedItineraryCreation; private long accessTime; private long egressTime; private int numAccesses; @@ -72,6 +74,7 @@ public class DebugTimingAggregator { private long tripPatternFilterTime; private long accessEgressTime; private long raptorSearchTime; + private long directTransitSearchTime; private long itineraryCreationTime; private long transitRouterTime; private long filteringTime; @@ -96,6 +99,7 @@ public DebugTimingAggregator(MeterRegistry registry, Collection rout .tags(tags) .register(registry); raptorSearchTimer = Timer.builder(ROUTING_RAPTOR).tags(tags).register(registry); + directTransitSearchTimer = Timer.builder("routing.directTransit").tags(tags).register(registry); accessEgressTimer = Timer.builder("routing.accessEgress").tags(tags).register(registry); tripPatternFilterTimer = Timer.builder("routing.tripPatternFiltering") .tags(tags) @@ -227,21 +231,38 @@ public void finishedAccessEgress(int numAccesses, int numEgresses) { * Record the time when we are finished with the raptor search. */ public void finishedRaptorSearch() { - finishedRaptorSearch = Timer.start(clock); if (finishedAccessEgress == null) { return; } raptorSearchTime = finishedAccessEgress.stop(raptorSearchTimer); } + public void startedDirectTransitSearch() { + startedDirectTransitSearch = Timer.start(clock); + } + + /** + * Record the time when we are finished with the direct transit search. + */ + public void finishedDirectTransitSearch() { + if (startedDirectTransitSearch == null) { + return; + } + directTransitSearchTime = startedDirectTransitSearch.stop(directTransitSearchTimer); + } + + public void startedItineraryCreation() { + startedItineraryCreation = Timer.start(clock); + } + /** * Record the time when we have created internal itinerary objects from the raptor responses. */ public void finishedItineraryCreation() { - if (finishedRaptorSearch == null) { + if (startedItineraryCreation == null) { return; } - itineraryCreationTime = finishedRaptorSearch.stop(itineraryCreationTimer); + itineraryCreationTime = startedItineraryCreation.stop(itineraryCreationTimer); } /** Record the time when we finished the transit router search */ @@ -272,6 +293,9 @@ public void finishedRouting() { log("│├ Egress routing (" + numEgresses + " egresses)", egressTime); log("││ Access/Egress routing", accessEgressTime); log("│├ Main routing", raptorSearchTime); + if (directTransitSearchTime > 0) { + log("│├ Direct transit routing", directTransitSearchTime); + } log("│├ Creating itineraries", itineraryCreationTime); log("├┴ Transit routing total", transitRouterTime); } diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerequest/DirectTransitRequestConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerequest/DirectTransitRequestConfig.java new file mode 100644 index 00000000000..c4c7e03812a --- /dev/null +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerequest/DirectTransitRequestConfig.java @@ -0,0 +1,85 @@ +package org.opentripplanner.standalone.config.routerequest; + +import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_9; + +import org.opentripplanner.routing.api.request.preference.DirectTransitPreferences; +import org.opentripplanner.standalone.config.framework.json.NodeAdapter; + +public class DirectTransitRequestConfig { + + static void map(NodeAdapter root, DirectTransitPreferences.Builder builder) { + NodeAdapter c = root + .of("directTransitSearch") + .since(V2_9) + .summary("Extend the search result with extra results using a direct transit search") + .description( + """ + The direct transit search finds results using a single transit leg, limited to a specified + cost relaxation. It will include results even if they are not optimal in regard to the criteria + in the main raptor search. + + This feature is off by default! + """ + ) + .asObject(); + + if (c.isEmpty()) { + return; + } + var dft = DirectTransitPreferences.DEFAULT; + + builder + .withEnabled( + c + .of("enabled") + .since(V2_9) + .summary("Enable the direct transit search") + .asBoolean(dft.enabled()) + ) + .withCostRelaxFunction( + c + .of("costRelaxFunction") + .since(V2_9) + .summary("The generalized-cost window for which paths to include.") + .description( + """ + A generalized-cost relax function of `2x + 10m` will include paths that have a cost up + to 2 times plus 10 minutes compared to the cheapest path. I.e. if the cheapest path has + a cost of 100m the results will include paths with a cost 210m. + """ + ) + .asCostLinearFunction(dft.costRelaxFunction()) + ) + .withExtraAccessEgressReluctance( + c + .of("extraAccessEgressReluctance") + .since(V2_9) + .summary("Add an extra cost factor to access/egress legs for these results") + .description( + """ + The cost for access/egress will be multiplied by this reluctance. This can be used to limit + the amount of walking. + """ + ) + .asDouble(dft.extraAccessEgressReluctance()) + ) + .withMaxAccessEgressDuration( + c + .of("maxAccessEgressDuration") + .since(V2_9) + .summary("A limit on the duration of access/egress for the direct transit search") + .description( + """ + This will limit the duration of access/egress for this search only. The default is the + as for the regular search. Setting this to a higher value than what is used for the regular + search will have have no effect. + + If set to zero, the search won't include results where access or egress is necessary. In + this case the direct transit search will only be used when searching to and from a stop + or station. + """ + ) + .asDuration(dft.maxAccessEgressDuration().orElse(null)) + ); + } +} diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerequest/RouteRequestConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerequest/RouteRequestConfig.java index ab05f0606a3..9b3467a6756 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/routerequest/RouteRequestConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerequest/RouteRequestConfig.java @@ -340,6 +340,8 @@ A related parameter (transferSlack) also helps avoid missed connections when the if (relaxTransitGroupPriorityValue != null) { builder.withRelaxTransitGroupPriority(CostLinearFunction.of(relaxTransitGroupPriorityValue)); } + + builder.withDirectTransitPreferences(it -> DirectTransitRequestConfig.map(c, it)); } private static void mapBikePreferences(NodeAdapter root, BikePreferences.Builder builder) { diff --git a/application/src/test/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/RaptorRequestMapperTest.java b/application/src/test/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/RaptorRequestMapperTest.java index 943b5ce2a32..821cb555e2b 100644 --- a/application/src/test/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/RaptorRequestMapperTest.java +++ b/application/src/test/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/RaptorRequestMapperTest.java @@ -257,7 +257,7 @@ void testRaptorDegugRequest() { } private static RaptorRequest map(RouteRequest request) { - return RaptorRequestMapper.mapRequest( + return RaptorRequestMapper.of( request, ZonedDateTime.now(), false, @@ -274,7 +274,7 @@ private static RaptorRequest map(RouteRequest request) { Set.of(), Set.of() ) - ); + ).mapRaptorRequest(); } private static void assertFeatureSet( diff --git a/application/src/test/java/org/opentripplanner/routing/api/request/preference/DirectTransitPreferencesTest.java b/application/src/test/java/org/opentripplanner/routing/api/request/preference/DirectTransitPreferencesTest.java new file mode 100644 index 00000000000..e716c8967d9 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/routing/api/request/preference/DirectTransitPreferencesTest.java @@ -0,0 +1,91 @@ +package org.opentripplanner.routing.api.request.preference; + +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 static org.opentripplanner.routing.api.request.preference.DirectTransitPreferences.DEFAULT; + +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.opentripplanner._support.asserts.AssertEqualsAndHashCode; +import org.opentripplanner.framework.model.Cost; +import org.opentripplanner.routing.api.request.framework.CostLinearFunction; + +class DirectTransitPreferencesTest { + + private static final CostLinearFunction COST_RELAX_FUNCTION = CostLinearFunction.of( + Cost.ONE_HOUR_WITH_TRANSIT, + 3.0 + ); + private static final double EXTRA_ACCESS_EGRESS_RELUCTANCE = 5.0; + private static final Duration MAX_ACCESS_EGRESS_DURATION = Duration.ZERO; + + private DirectTransitPreferences subject = DirectTransitPreferences.of() + .withEnabled(true) + .withCostRelaxFunction(COST_RELAX_FUNCTION) + .withExtraAccessEgressReluctance(EXTRA_ACCESS_EGRESS_RELUCTANCE) + .withMaxAccessEgressDuration(MAX_ACCESS_EGRESS_DURATION) + .build(); + + @Test + void enabled() { + assertFalse(DEFAULT.enabled()); + assertTrue(subject.enabled()); + } + + @Test + void costRelaxFunction() { + assertEquals(DirectTransitPreferences.DEFAULT_COST_RELAX_FUNCTION, DEFAULT.costRelaxFunction()); + assertEquals(COST_RELAX_FUNCTION, subject.costRelaxFunction()); + } + + @Test + void extraAccessEgressReluctance() { + assertEquals( + DirectTransitPreferences.DEFAULT_RELUCTANCE, + DEFAULT.extraAccessEgressReluctance() + ); + assertEquals(EXTRA_ACCESS_EGRESS_RELUCTANCE, subject.extraAccessEgressReluctance()); + } + + @Test + void maxAccessEgressDuration() { + assertEquals(Optional.empty(), DEFAULT.maxAccessEgressDuration()); + assertEquals(Optional.of(Duration.ZERO), subject.maxAccessEgressDuration()); + } + + @Test + void testEqualsAndHashCode() { + var sameAs = DirectTransitPreferences.of() + .withEnabled(true) + .withCostRelaxFunction(COST_RELAX_FUNCTION) + .withExtraAccessEgressReluctance(EXTRA_ACCESS_EGRESS_RELUCTANCE) + .withMaxAccessEgressDuration(Duration.ZERO) + .build(); + + AssertEqualsAndHashCode.verify(subject).differentFrom(DEFAULT).sameAs(sameAs); + } + + @Test + void testToString() { + assertEquals( + "DirectTransitPreferences{not enabled}", + DirectTransitPreferences.DEFAULT.toString() + ); + assertEquals( + "DirectTransitPreferences{" + + "costRelaxFunction: 1h + 3.0 t, " + + "extraAccessEgressReluctance: 5.0, " + + "maxAccessEgressDuration: 0s" + + "}", + subject.toString() + ); + // We only want to log "not enabled" if off, the rest of the state + // is irelevant. + assertEquals( + "DirectTransitPreferences{not enabled}", + subject.copyOf().withEnabled(false).build().toString() + ); + } +} diff --git a/application/src/test/resources/standalone/config/router-config.json b/application/src/test/resources/standalone/config/router-config.json index 0b387db5177..fd3d00cbf25 100644 --- a/application/src/test/resources/standalone/config/router-config.json +++ b/application/src/test/resources/standalone/config/router-config.json @@ -160,6 +160,12 @@ "maxSlope": 0.083, "slopeExceededReluctance": 1, "stairsReluctance": 100 + }, + "directTransitSearch": { + "enabled": false, + "costRelaxFunction": "15m + 1.5t", + "maxAccessEgressDuration": "5m", + "extraAccessEgressReluctance": 2 } }, "flex": { diff --git a/doc/user/RouteRequest.md b/doc/user/RouteRequest.md index 005ae8e92d6..e3d90974f0b 100644 --- a/doc/user/RouteRequest.md +++ b/doc/user/RouteRequest.md @@ -106,6 +106,11 @@ and in the [transferRequests in build-config.json](BuildConfiguration.md#transfe |       useAvailabilityInformation | `boolean` | Whether or not vehicle rental availability information will be used to plan vehicle rental trips. | *Optional* | `false` | 2.0 | |       [allowedNetworks](#rd_car_rental_allowedNetworks) | `string[]` | The vehicle rental networks which may be used. If empty all networks may be used. | *Optional* | | 2.1 | |       [bannedNetworks](#rd_car_rental_bannedNetworks) | `string[]` | The vehicle rental networks which may not be used. If empty, no networks are banned. | *Optional* | | 2.1 | +| [directTransitSearch](#rd_directTransitSearch) | `object` | Extend the search result with extra results using a direct transit search | *Optional* | | 2.9 | +|    [costRelaxFunction](#rd_directTransitSearch_costRelaxFunction) | `cost-linear-function` | The generalized-cost window for which paths to include. | *Optional* | `"15m + 1.50 t"` | 2.9 | +|    enabled | `boolean` | Enable the direct transit search | *Optional* | `false` | 2.9 | +|    [extraAccessEgressReluctance](#rd_directTransitSearch_extraAccessEgressReluctance) | `double` | Add an extra cost factor to access/egress legs for these results | *Optional* | `1.0` | 2.9 | +|    [maxAccessEgressDuration](#rd_directTransitSearch_maxAccessEgressDuration) | `duration` | A limit on the duration of access/egress for the direct transit search | *Optional* | | 2.9 | | elevator | `object` | Elevator preferences. | *Optional* | | 2.9 | |    boardCost | `integer` | What is the cost of boarding a elevator? | *Optional* | `15` | 2.9 | |    boardSlack | `duration` | How long it takes to get on an elevator, on average. | *Optional* | `"PT1M30S"` | 2.9 | @@ -692,6 +697,59 @@ The vehicle rental networks which may be used. If empty all networks may be used The vehicle rental networks which may not be used. If empty, no networks are banned. +

directTransitSearch

+ +**Since version:** `2.9` ∙ **Type:** `object` ∙ **Cardinality:** `Optional` +**Path:** /routingDefaults + +Extend the search result with extra results using a direct transit search + +The direct transit search finds results using a single transit leg, limited to a specified +cost relaxation. It will include results even if they are not optimal in regard to the criteria +in the main raptor search. + +This feature is off by default! + + +

costRelaxFunction

+ +**Since version:** `2.9` ∙ **Type:** `cost-linear-function` ∙ **Cardinality:** `Optional` ∙ **Default value:** `"15m + 1.50 t"` +**Path:** /routingDefaults/directTransitSearch + +The generalized-cost window for which paths to include. + +A generalized-cost relax function of `2x + 10m` will include paths that have a cost up +to 2 times plus 10 minutes compared to the cheapest path. I.e. if the cheapest path has +a cost of 100m the results will include paths with a cost 210m. + + +

extraAccessEgressReluctance

+ +**Since version:** `2.9` ∙ **Type:** `double` ∙ **Cardinality:** `Optional` ∙ **Default value:** `1.0` +**Path:** /routingDefaults/directTransitSearch + +Add an extra cost factor to access/egress legs for these results + +The cost for access/egress will be multiplied by this reluctance. This can be used to limit +the amount of walking. + + +

maxAccessEgressDuration

+ +**Since version:** `2.9` ∙ **Type:** `duration` ∙ **Cardinality:** `Optional` +**Path:** /routingDefaults/directTransitSearch + +A limit on the duration of access/egress for the direct transit search + +This will limit the duration of access/egress for this search only. The default is the +as for the regular search. Setting this to a higher value than what is used for the regular +search will have have no effect. + +If set to zero, the search won't include results where access or egress is necessary. In +this case the direct transit search will only be used when searching to and from a stop +or station. + +

itineraryFilters

**Since version:** `2.0` ∙ **Type:** `object` ∙ **Cardinality:** `Optional` @@ -1361,6 +1419,12 @@ include stairs as a last result. "maxSlope" : 0.083, "slopeExceededReluctance" : 1, "stairsReluctance" : 100 + }, + "directTransitSearch" : { + "enabled" : false, + "costRelaxFunction" : "15m + 1.5t", + "maxAccessEgressDuration" : "5m", + "extraAccessEgressReluctance" : 2 } } } diff --git a/doc/user/RouterConfiguration.md b/doc/user/RouterConfiguration.md index ba0ccb7e55c..249d33710c0 100644 --- a/doc/user/RouterConfiguration.md +++ b/doc/user/RouterConfiguration.md @@ -656,6 +656,12 @@ Used to group requests when monitoring OTP. "maxSlope" : 0.083, "slopeExceededReluctance" : 1, "stairsReluctance" : 100 + }, + "directTransitSearch" : { + "enabled" : false, + "costRelaxFunction" : "15m + 1.5t", + "maxAccessEgressDuration" : "5m", + "extraAccessEgressReluctance" : 2 } }, "flex" : { diff --git a/raptor/src/main/java/org/opentripplanner/raptor/RaptorService.java b/raptor/src/main/java/org/opentripplanner/raptor/RaptorService.java index ffc8582f4ea..2d6693c796a 100644 --- a/raptor/src/main/java/org/opentripplanner/raptor/RaptorService.java +++ b/raptor/src/main/java/org/opentripplanner/raptor/RaptorService.java @@ -1,11 +1,15 @@ package org.opentripplanner.raptor; +import java.util.Collection; import java.util.stream.Collectors; import javax.annotation.Nullable; import org.opentripplanner.raptor.api.model.RaptorTripSchedule; +import org.opentripplanner.raptor.api.path.RaptorPath; import org.opentripplanner.raptor.api.request.RaptorRequest; import org.opentripplanner.raptor.api.response.RaptorResponse; import org.opentripplanner.raptor.configure.RaptorConfig; +import org.opentripplanner.raptor.direct.api.RaptorDirectTransitRequest; +import org.opentripplanner.raptor.direct.configure.DirectTransitSearchFactory; import org.opentripplanner.raptor.service.DefaultStopArrivals; import org.opentripplanner.raptor.service.HeuristicSearchTask; import org.opentripplanner.raptor.service.RangeRaptorDynamicSearch; @@ -58,6 +62,18 @@ public RaptorResponse route( return response; } + /** + * Find all transit options for the given request. The result should contain ALL options, + * not just the parato-optimal result return by the {@link #route(RaptorRequest, RaptorTransitDataProvider)} + * method. + */ + public Collection> findAllDirectTransit( + RaptorDirectTransitRequest request, + RaptorTransitDataProvider transitData + ) { + return DirectTransitSearchFactory.createSearch(request, transitData).route(); + } + /** * TODO Add back the possibility to compare heuristics using a test - like the SpeedTest, * but maybe better to make a separate test. diff --git a/raptor/src/main/java/org/opentripplanner/raptor/direct/api/RaptorDirectTransitRequest.java b/raptor/src/main/java/org/opentripplanner/raptor/direct/api/RaptorDirectTransitRequest.java new file mode 100644 index 00000000000..9bf10d9ac0e --- /dev/null +++ b/raptor/src/main/java/org/opentripplanner/raptor/direct/api/RaptorDirectTransitRequest.java @@ -0,0 +1,158 @@ +package org.opentripplanner.raptor.direct.api; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import org.opentripplanner.raptor.api.model.GeneralizedCostRelaxFunction; +import org.opentripplanner.raptor.api.model.RaptorAccessEgress; +import org.opentripplanner.raptor.api.model.RaptorConstants; +import org.opentripplanner.raptor.api.model.RelaxFunction; +import org.opentripplanner.utils.tostring.ToStringBuilder; + +/// All input parameters to do a direct search. +public final class RaptorDirectTransitRequest { + + private final int earliestDepartureTime; + private final int searchWindowInSeconds; + private final RelaxFunction relaxC1; + private final Collection accessPaths; + private final Collection egressPaths; + + private RaptorDirectTransitRequest() { + this.earliestDepartureTime = RaptorConstants.TIME_NOT_SET; + this.searchWindowInSeconds = RaptorConstants.NOT_SET; + this.relaxC1 = GeneralizedCostRelaxFunction.NORMAL; + this.accessPaths = List.of(); + this.egressPaths = List.of(); + } + + public RaptorDirectTransitRequest( + int earliestDepartureTime, + int searchWindowInSeconds, + RelaxFunction relaxC1, + Collection accessPaths, + Collection egressPaths + ) { + this.earliestDepartureTime = earliestDepartureTime; + this.searchWindowInSeconds = searchWindowInSeconds; + this.relaxC1 = Objects.requireNonNull(relaxC1); + this.accessPaths = Objects.requireNonNull(accessPaths); + this.egressPaths = Objects.requireNonNull(egressPaths); + verify(); + } + + public static RaptorDirectTransitRequest defaults() { + return new RaptorDirectTransitRequest(); + } + + public static RaptorDirectTransitRequestBuilder of() { + return new RaptorDirectTransitRequestBuilder(defaults()); + } + + /// The earliest a journey can depart from the origin. The unit is seconds since midnight. + /// Inclusive. + /// + /// In the case of a 'depart after' search this is a required. In the case of a 'arrive by' search + /// this is optional, but it will improve performance if it is set. + public int earliestDepartureTime() { + return earliestDepartureTime; + } + + public boolean isEarliestDepartureTimeSet() { + return earliestDepartureTime != RaptorConstants.TIME_NOT_SET; + } + + /// The time window used to search. The unit is seconds. + /// + /// For a *depart-by-search*, this is added to the 'earliestDepartureTime' to find the + /// 'latestDepartureTime'. + /// + /// For an *arrive-by-search* this is used to calculate the 'earliestArrivalTime'. The algorithm + /// will find all optimal travels within the given time window. + /// + /// Set the search window to 0 (zero) to run 1 iteration. + /// + /// Required. Must be a positive integer or 0(zero). + public int searchWindowInSeconds() { + return searchWindowInSeconds; + } + + /// The relax function specifies which paths to include. + /// + /// A relax function of `2x + 10m` will include paths that have a c1 cost up to 2 times plus 10 + /// minutes compared to the cheapest path. I.e. if the cheapest path has a cost of 100 min the + /// results will include paths with a cost 210 min. + public RelaxFunction relaxC1() { + return relaxC1; + } + + /// List of access paths from the origin to all transit stops using the street network. + /// + /// Required, at least one access path must exist. + public Collection accessPaths() { + return accessPaths; + } + + /// List of all possible egress paths to reach the destination using the street network. + /// + /// NOTE! The {@link RaptorTransfer#stop()} is the stop where the egress path start, NOT the + /// destination - think of it as a reversed path. + /// + /// Required, at least one egress path must exist. + public Collection egressPaths() { + return egressPaths; + } + + @Override + public int hashCode() { + return Objects.hash( + earliestDepartureTime, + searchWindowInSeconds, + relaxC1, + accessPaths, + egressPaths + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o instanceof RaptorDirectTransitRequest that) { + return ( + earliestDepartureTime == that.earliestDepartureTime && + searchWindowInSeconds == that.searchWindowInSeconds && + relaxC1.equals(that.relaxC1) && + accessPaths.equals(that.accessPaths) && + egressPaths.equals(that.egressPaths) + ); + } + return false; + } + + @Override + public String toString() { + var dft = defaults(); + return ToStringBuilder.of(RaptorDirectTransitRequest.class) + .addServiceTime("earliestDepartureTime", earliestDepartureTime, dft.earliestDepartureTime) + .addDurationSec("searchWindow", searchWindowInSeconds, dft.searchWindowInSeconds) + .addObj("relaxC1", relaxC1, dft.relaxC1) + .addCollection("accessPaths", accessPaths, 5, RaptorAccessEgress::defaultToString) + .addCollection("egressPaths", egressPaths, 5, RaptorAccessEgress::defaultToString) + .toString(); + } + + /* private methods */ + private void verify() { + assertProperty(isEarliestDepartureTimeSet(), "'earliestDepartureTime' is required."); + assertProperty(!accessPaths.isEmpty(), "At least one 'accessPath' is required."); + assertProperty(!egressPaths.isEmpty(), "At least one 'egressPath' is required."); + } + + private static void assertProperty(boolean predicate, String message) { + if (!predicate) { + throw new IllegalArgumentException(message); + } + } +} diff --git a/raptor/src/main/java/org/opentripplanner/raptor/direct/api/RaptorDirectTransitRequestBuilder.java b/raptor/src/main/java/org/opentripplanner/raptor/direct/api/RaptorDirectTransitRequestBuilder.java new file mode 100644 index 00000000000..c3d30a8674f --- /dev/null +++ b/raptor/src/main/java/org/opentripplanner/raptor/direct/api/RaptorDirectTransitRequestBuilder.java @@ -0,0 +1,94 @@ +package org.opentripplanner.raptor.direct.api; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import org.opentripplanner.raptor.api.model.RaptorAccessEgress; +import org.opentripplanner.raptor.api.model.RaptorConstants; +import org.opentripplanner.raptor.api.model.RelaxFunction; +import org.opentripplanner.raptor.api.request.SearchParams; + +/** + * Mutable version of {@link SearchParams}. + * + * @param The TripSchedule type defined by the user of the raptor API. + */ +@SuppressWarnings("UnusedReturnValue") +public class RaptorDirectTransitRequestBuilder { + + private int earliestDepartureTime; + private int searchWindowInSeconds; + private RelaxFunction relaxC1; + private final Collection accessPaths = new ArrayList<>(); + private final Collection egressPaths = new ArrayList<>(); + + public RaptorDirectTransitRequestBuilder(RaptorDirectTransitRequest defaults) { + this.earliestDepartureTime = defaults.earliestDepartureTime(); + this.searchWindowInSeconds = defaults.searchWindowInSeconds(); + this.relaxC1 = defaults.relaxC1(); + this.accessPaths.addAll(defaults.accessPaths()); + this.egressPaths.addAll(defaults.egressPaths()); + } + + public RaptorDirectTransitRequestBuilder earliestDepartureTime(int earliestDepartureTime) { + this.earliestDepartureTime = earliestDepartureTime; + return this; + } + + public RaptorDirectTransitRequestBuilder searchWindowInSeconds(int searchWindowInSeconds) { + this.searchWindowInSeconds = searchWindowInSeconds; + return this; + } + + public RaptorDirectTransitRequestBuilder searchWindow(Duration searchWindow) { + return searchWindowInSeconds( + searchWindow == null ? RaptorConstants.NOT_SET : (int) searchWindow.toSeconds() + ); + } + + public RaptorDirectTransitRequestBuilder withRelaxC1(RelaxFunction relaxC1) { + this.relaxC1 = relaxC1; + return this; + } + + public Collection accessPaths() { + return accessPaths; + } + + public RaptorDirectTransitRequestBuilder addAccessPaths( + Collection accessPaths + ) { + this.accessPaths.addAll(accessPaths); + return this; + } + + public RaptorDirectTransitRequestBuilder addAccessPaths(RaptorAccessEgress... accessPaths) { + return addAccessPaths(Arrays.asList(accessPaths)); + } + + public Collection egressPaths() { + return egressPaths; + } + + public RaptorDirectTransitRequestBuilder addEgressPaths( + Collection egressPaths + ) { + this.egressPaths.addAll(egressPaths); + return this; + } + + public RaptorDirectTransitRequestBuilder addEgressPaths(RaptorAccessEgress... egressPaths) { + return addEgressPaths(Arrays.asList(egressPaths)); + } + + public RaptorDirectTransitRequest build() { + return new RaptorDirectTransitRequest( + earliestDepartureTime, + searchWindowInSeconds, + relaxC1, + accessPaths, + egressPaths + ); + } +} diff --git a/raptor/src/main/java/org/opentripplanner/raptor/direct/configure/DirectTransitSearchFactory.java b/raptor/src/main/java/org/opentripplanner/raptor/direct/configure/DirectTransitSearchFactory.java new file mode 100644 index 00000000000..da9658ca23c --- /dev/null +++ b/raptor/src/main/java/org/opentripplanner/raptor/direct/configure/DirectTransitSearchFactory.java @@ -0,0 +1,23 @@ +package org.opentripplanner.raptor.direct.configure; + +import org.opentripplanner.raptor.api.model.RaptorTripSchedule; +import org.opentripplanner.raptor.direct.api.RaptorDirectTransitRequest; +import org.opentripplanner.raptor.direct.service.DirectTransitSearch; +import org.opentripplanner.raptor.spi.RaptorTransitDataProvider; + +public class DirectTransitSearchFactory { + + public static DirectTransitSearch createSearch( + RaptorDirectTransitRequest request, + RaptorTransitDataProvider data + ) { + return new DirectTransitSearch( + request.earliestDepartureTime(), + request.searchWindowInSeconds(), + request.relaxC1(), + request.accessPaths(), + request.egressPaths(), + data + ); + } +} diff --git a/raptor/src/main/java/org/opentripplanner/raptor/direct/service/DirectTransitSearch.java b/raptor/src/main/java/org/opentripplanner/raptor/direct/service/DirectTransitSearch.java new file mode 100644 index 00000000000..dc629675149 --- /dev/null +++ b/raptor/src/main/java/org/opentripplanner/raptor/direct/service/DirectTransitSearch.java @@ -0,0 +1,230 @@ +package org.opentripplanner.raptor.direct.service; + +import java.util.ArrayList; +import java.util.BitSet; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.opentripplanner.raptor.api.model.RaptorAccessEgress; +import org.opentripplanner.raptor.api.model.RaptorConstants; +import org.opentripplanner.raptor.api.model.RaptorTripSchedule; +import org.opentripplanner.raptor.api.model.RelaxFunction; +import org.opentripplanner.raptor.api.model.SearchDirection; +import org.opentripplanner.raptor.api.path.RaptorPath; +import org.opentripplanner.raptor.path.PathBuilder; +import org.opentripplanner.raptor.spi.BoardAndAlightTime; +import org.opentripplanner.raptor.spi.IntIterator; +import org.opentripplanner.raptor.spi.RaptorRoute; +import org.opentripplanner.raptor.spi.RaptorTransitDataProvider; +import org.opentripplanner.raptor.util.BitSetIterator; +import org.opentripplanner.raptor.util.paretoset.ParetoComparator; +import org.opentripplanner.raptor.util.paretoset.ParetoSet; + +/// The direct transit search finds paths using a single transit leg, limited to a +/// specified cost window. It will find paths even if they are not optimal in regard to the criteria +/// in the main raptor search. +public class DirectTransitSearch { + + private final int earliestDepartureTime; + private final int latestDepartureTime; + private final RelaxFunction relaxC1; + private final Collection accesses; + private final Collection egresses; + private final RaptorTransitDataProvider data; + + /* Variables used during the search (mutable) */ + + private int currentRouteBoardSlack = RaptorConstants.NOT_SET; + + public DirectTransitSearch( + int earliestDepartureTime, + int searchWindowInSeconds, + RelaxFunction relaxC1, + Collection accesses, + Collection egresses, + RaptorTransitDataProvider data + ) { + this.earliestDepartureTime = earliestDepartureTime; + this.latestDepartureTime = earliestDepartureTime + searchWindowInSeconds; + this.relaxC1 = relaxC1; + this.accesses = accesses; + this.egresses = egresses; + this.data = data; + } + + /// Run the search + public Collection> route() { + var results = ParetoSet.>of(new DestinationArrivalComparator<>(relaxC1)); + + var routes = data.routeIndexIterator(findAllAccessStopIndexes()); + + while (routes.hasNext()) { + var route = data.getRouteForIndex(routes.next()); + var paths = routeSearch(route); + results.addAll(paths); + } + return results; + } + + private IntIterator findAllAccessStopIndexes() { + BitSet accessStopBitSet = new BitSet(); + for (RaptorAccessEgress it : accesses) { + accessStopBitSet.set(it.stop()); + } + return new BitSetIterator(accessStopBitSet); + } + + /// First find ONE path for each combination of access/egress. This corresponds to the + /// first iteration of a RangeRaptor search. We will later expand this to all trip schedules + /// within the search-window. All paths with the same (route, access, and egress) will have + /// almost identical cost, so we can use this to prune the set of paths before expanding the + /// timetable. For each route/pattern we only want the best combination of access and egress. + private List> routeSearch(RaptorRoute route) { + this.currentRouteBoardSlack = data.slackProvider().boardSlack(route.pattern().slackIndex()); + RaptorPath bestPath = null; + + for (var access : accesses) { + var pattern = route.pattern(); + int boardPos = pattern.findStopPositionAfter(0, access.stop()); + + if (boardPos == -1) { + continue; + } + + for (var egress : egresses) { + int alightPos = pattern.findStopPositionAfter(boardPos + 1, egress.stop()); + + if (alightPos == -1) { + continue; + } + + var pathOp = findFirstPathInSearchWindow(route, access, egress, boardPos, alightPos); + + if (pathOp.isPresent()) { + var candidate = pathOp.get(); + if (bestPath == null || candidate.c1() < bestPath.c1()) { + bestPath = candidate; + } + } + } + } + this.currentRouteBoardSlack = RaptorConstants.NOT_SET; + // Expand the best-path into all paths within the search-window + return bestPath == null ? List.of() : findAllPathsInSearchWindow(route, bestPath); + } + + private Optional> findFirstPathInSearchWindow( + RaptorRoute route, + RaptorAccessEgress access, + RaptorAccessEgress egress, + int boardPos, + int alightPos + ) { + var timetable = route.timetable(); + var search = timetable.tripSearch(SearchDirection.FORWARD); + int boardTime = earliestDepartureTime + access.durationInSeconds() + currentRouteBoardSlack; + + // find the first possible trip + var boardEvent = search.search(boardTime, boardPos); + + if (boardEvent.empty()) { + return Optional.empty(); + } + var path = mapToPath(boardEvent.trip(), access, egress, boardPos, alightPos); + + if (path.startTime() < earliestDepartureTime) { + throw new IllegalStateException( + "This should not happen. There is a mismatch between the calculated board time/" + + "trip search and the assembly of the path." + ); + } + + if (path.startTime() < latestDepartureTime) { + return Optional.of(path); + } + return Optional.empty(); + } + + private List> findAllPathsInSearchWindow( + RaptorRoute route, + RaptorPath firstPath + ) { + var transitLeg = firstPath.accessLeg().nextLeg().asTransitLeg(); + int tripScheduleStartIndex = transitLeg.trip().tripSortIndex() + 1; + var access = firstPath.accessLeg().access(); + var egress = firstPath.egressLeg().egress(); + int boardPos = transitLeg.getFromStopPosition(); + int alightPos = transitLeg.getToStopPosition(); + var timetable = route.timetable(); + + var results = new ArrayList>(); + results.add(firstPath); + + for (int i = tripScheduleStartIndex; i < timetable.numberOfTripSchedules(); i++) { + var schedule = timetable.getTripSchedule(i); + var path = mapToPath(schedule, access, egress, boardPos, alightPos); + + // We only need to check the end of the search-window, since we know the {@code firstPath} is + // inside. All successive schedules will therefore also be after the + // {@code earliestDepartureTime}. + if (path.startTime() > latestDepartureTime) { + return results; + } + results.add(path); + } + return results; + } + + private RaptorPath mapToPath( + T schedule, + RaptorAccessEgress access, + RaptorAccessEgress egress, + int boardPos, + int alightPos + ) { + var times = new BoardAndAlightTime(schedule, boardPos, alightPos); + + // This is the range-raptor iteration start time, this is required meta-info + var iterationDepartureTime = calculateIterationDepartureTime( + access.durationInSeconds(), + schedule.departure(boardPos), + currentRouteBoardSlack + ); + + var pathBuilder = PathBuilder.tailPathBuilder( + data.slackProvider(), + iterationDepartureTime, + data.multiCriteriaCostCalculator(), + null, + null + ); + pathBuilder.access(access); + pathBuilder.transit(schedule, times); + pathBuilder.egress(egress); + return pathBuilder.build(); + } + + static int calculateIterationDepartureTime(int accessDuration, int boardTime, int boardSlack) { + return ((boardTime - (accessDuration + boardSlack)) / 60) * 60; + } + + /// This comparator uses a relax function on the cost to decide if a path dominates another. + private static class DestinationArrivalComparator + implements ParetoComparator> { + + private final RelaxFunction relaxFunction; + + public DestinationArrivalComparator(RelaxFunction relaxFunction) { + this.relaxFunction = relaxFunction; + } + + @Override + public boolean leftDominanceExist(RaptorPath left, RaptorPath right) { + return ( + left.startTime() > right.startTime() || + left.endTime() < right.endTime() || + left.c1() < relaxFunction.relax(right.c1()) + ); + } + } +} diff --git a/raptor/src/main/java/org/opentripplanner/raptor/spi/RaptorSlackProvider.java b/raptor/src/main/java/org/opentripplanner/raptor/spi/RaptorSlackProvider.java index a4ec1815d8c..c233155f6e9 100644 --- a/raptor/src/main/java/org/opentripplanner/raptor/spi/RaptorSlackProvider.java +++ b/raptor/src/main/java/org/opentripplanner/raptor/spi/RaptorSlackProvider.java @@ -20,6 +20,8 @@ public interface RaptorSlackProvider { * calculate the earliest-bord-time before boarding). *

* Unit: seconds. + * + * @param slackIndex The slackIndex is provided by the RaptorTripPattern */ int boardSlack(int slackIndex); diff --git a/raptor/src/test/java/org/opentripplanner/raptor/direct/service/DirectTransitSearchTest.java b/raptor/src/test/java/org/opentripplanner/raptor/direct/service/DirectTransitSearchTest.java new file mode 100644 index 00000000000..a787091bc4a --- /dev/null +++ b/raptor/src/test/java/org/opentripplanner/raptor/direct/service/DirectTransitSearchTest.java @@ -0,0 +1,30 @@ +package org.opentripplanner.raptor.direct.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class DirectTransitSearchTest { + + @Test + void calculateIterationDepartureTime() { + assertEquals(0, iterationDeparture(0, 59, 0)); + assertEquals(60, iterationDeparture(0, 60, 0)); + assertEquals(60, iterationDeparture(0, 61, 0)); + + assertEquals(0, iterationDeparture(0, 60, 1)); + assertEquals(0, iterationDeparture(1, 60, 0)); + + assertEquals(240, iterationDeparture(60, 360, 60)); + assertEquals(180, iterationDeparture(60, 360, 120)); + assertEquals(60, iterationDeparture(0, 61, 0)); + } + + private int iterationDeparture(int accessDuration, int boardTime, int boardSlack) { + return DirectTransitSearch.calculateIterationDepartureTime( + accessDuration, + boardTime, + boardSlack + ); + } +} diff --git a/raptor/src/test/java/org/opentripplanner/raptor/moduletests/M01_DirectTransitWithRoutesWithinRelaxC1.java b/raptor/src/test/java/org/opentripplanner/raptor/moduletests/M01_DirectTransitWithRoutesWithinRelaxC1.java new file mode 100644 index 00000000000..4c526e66e32 --- /dev/null +++ b/raptor/src/test/java/org/opentripplanner/raptor/moduletests/M01_DirectTransitWithRoutesWithinRelaxC1.java @@ -0,0 +1,63 @@ +package org.opentripplanner.raptor.moduletests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.opentripplanner.raptor._data.api.PathUtils.pathsToString; +import static org.opentripplanner.raptor._data.transit.TestRoute.route; +import static org.opentripplanner.raptor._data.transit.TestTripPattern.pattern; +import static org.opentripplanner.raptor._data.transit.TestTripSchedule.schedule; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.raptor.RaptorService; +import org.opentripplanner.raptor._data.RaptorTestConstants; +import org.opentripplanner.raptor._data.transit.TestAccessEgress; +import org.opentripplanner.raptor._data.transit.TestTransitData; +import org.opentripplanner.raptor._data.transit.TestTripSchedule; +import org.opentripplanner.raptor.api.model.GeneralizedCostRelaxFunction; +import org.opentripplanner.raptor.api.model.RaptorCostConverter; +import org.opentripplanner.raptor.configure.RaptorTestFactory; +import org.opentripplanner.raptor.direct.api.RaptorDirectTransitRequest; + +/** + * FEATURE UNDER TEST + *

+ * The direct transit search should return both the optimal path and the slightly slower + * path - the `relaxC1` define the slack. Non-optimal paths should not be returned. + */ +class M01_DirectTransitWithRoutesWithinRelaxC1 implements RaptorTestConstants { + + private final TestTransitData data = new TestTransitData(); + private final RaptorService raptorService = RaptorTestFactory.raptorService(); + + @BeforeEach + void setup() { + data + .withRoute(route(pattern("R1", STOP_B, STOP_D)).withTimetable(schedule("00:02 00:03:40"))) + .withRoute(route(pattern("R2", STOP_B, STOP_D)).withTimetable(schedule("00:02 00:08:59"))) + .withRoute(route(pattern("R3", STOP_B, STOP_D)).withTimetable(schedule("00:02 00:09:00"))) + .withBoardCost(0); + } + + @Test + void testRelaxedLimitedTransferSearch() { + var request = RaptorDirectTransitRequest.of() + .earliestDepartureTime(T00_00) + .searchWindowInSeconds(D10m) + .addAccessPaths(TestAccessEgress.walk(STOP_B, D30s)) + .addEgressPaths(TestAccessEgress.walk(STOP_D, D20s)) + .withRelaxC1(GeneralizedCostRelaxFunction.of(2.0, RaptorCostConverter.toRaptorCost(D2m))) + .build(); + + var paths = raptorService.findAllDirectTransit(request, data); + + // The best option has a generalized-cost of C₁200 (access:60 + transit:100 + egress:40). + // With a `relaxC1(c1) -> 2.0 * c1 + 120` the c1 limit is + * The direct transit search should return all trips on the same route within the search window. + */ +public class M02_DirectTransitWithTripsInSearchWindow implements RaptorTestConstants { + + private final TestTransitData data = new TestTransitData(); + private final RaptorService raptorService = RaptorTestFactory.raptorService(); + + @BeforeEach + void setup() { + data.withTimetable( + "R1", + // The searchWindow is set to 00:00 to 00:03, so with 30s access the last trip starts after + // the latest-depature-time. + """ + A B + 00:02 00:04 + 00:03 00:05 + 00:04 00:06 + """ + ); + } + + @Test + void testRelaxedLimitedTransferSearch() { + var request = RaptorDirectTransitRequest.of() + .earliestDepartureTime(T00_00) + .searchWindowInSeconds(D3m) + .addAccessPaths(TestAccessEgress.walk(STOP_A, D30s)) + .addEgressPaths(TestAccessEgress.walk(STOP_B, D20s)) + .build(); + + var result = raptorService.findAllDirectTransit(request, data); + assertEquals( + """ + Walk 30s ~ A ~ BUS R1 0:02 0:04 ~ B ~ Walk 20s [0:01:30 0:04:20 2m50s Tₙ0 C₁820] + Walk 30s ~ A ~ BUS R1 0:03 0:05 ~ B ~ Walk 20s [0:02:30 0:05:20 2m50s Tₙ0 C₁820]""", + pathsToString(result) + ); + } +} diff --git a/raptor/src/test/java/org/opentripplanner/raptor/moduletests/M03_DirectTransitSearchWindow.java b/raptor/src/test/java/org/opentripplanner/raptor/moduletests/M03_DirectTransitSearchWindow.java new file mode 100644 index 00000000000..e2f37d797bc --- /dev/null +++ b/raptor/src/test/java/org/opentripplanner/raptor/moduletests/M03_DirectTransitSearchWindow.java @@ -0,0 +1,57 @@ +package org.opentripplanner.raptor.moduletests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.opentripplanner.raptor._data.api.PathUtils.pathsToString; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.raptor.RaptorService; +import org.opentripplanner.raptor._data.RaptorTestConstants; +import org.opentripplanner.raptor._data.transit.TestAccessEgress; +import org.opentripplanner.raptor._data.transit.TestTransitData; +import org.opentripplanner.raptor._data.transit.TestTripSchedule; +import org.opentripplanner.raptor.configure.RaptorTestFactory; +import org.opentripplanner.raptor.direct.api.RaptorDirectTransitRequest; + +/** + * FEATURE UNDER TEST + *

+ * The direct transit search should only return trips in the search window + */ +public class M03_DirectTransitSearchWindow implements RaptorTestConstants { + + private final TestTransitData data = new TestTransitData(); + private final RaptorService raptorService = RaptorTestFactory.raptorService(); + + @BeforeEach + void setup() { + data.withTimetable( + "R1", + """ + A B + 00:02 00:03 + 00:03 00:04 + 00:04 00:05 + 00:05 00:06 + """ + ); + } + + @Test + void testRelaxedSearchWindow() { + var request = RaptorDirectTransitRequest.of() + .earliestDepartureTime(T00_02) + .searchWindowInSeconds(D1m) + .addAccessPaths(TestAccessEgress.walk(STOP_A, D1m)) + .addEgressPaths(TestAccessEgress.walk(STOP_B, D1m)) + .build(); + + var result = raptorService.findAllDirectTransit(request, data); + + assertEquals( + "Walk 1m ~ A ~ BUS R1 0:03 0:04 ~ B ~ Walk 1m [0:02 0:05 3m Tₙ0 C₁900]\n" + + "Walk 1m ~ A ~ BUS R1 0:04 0:05 ~ B ~ Walk 1m [0:03 0:06 3m Tₙ0 C₁900]", + pathsToString(result) + ); + } +} diff --git a/raptor/src/test/java/org/opentripplanner/raptor/moduletests/M04_DirectTransitCostLimit.java b/raptor/src/test/java/org/opentripplanner/raptor/moduletests/M04_DirectTransitCostLimit.java new file mode 100644 index 00000000000..3a7e146f4b7 --- /dev/null +++ b/raptor/src/test/java/org/opentripplanner/raptor/moduletests/M04_DirectTransitCostLimit.java @@ -0,0 +1,76 @@ +package org.opentripplanner.raptor.moduletests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.opentripplanner.raptor._data.api.PathUtils.pathsToString; +import static org.opentripplanner.raptor._data.transit.TestRoute.route; +import static org.opentripplanner.raptor._data.transit.TestTripPattern.pattern; +import static org.opentripplanner.raptor._data.transit.TestTripSchedule.schedule; + +import org.junit.jupiter.api.Test; +import org.opentripplanner.raptor.RaptorService; +import org.opentripplanner.raptor._data.RaptorTestConstants; +import org.opentripplanner.raptor._data.transit.TestAccessEgress; +import org.opentripplanner.raptor._data.transit.TestTransitData; +import org.opentripplanner.raptor._data.transit.TestTripSchedule; +import org.opentripplanner.raptor.api.model.GeneralizedCostRelaxFunction; +import org.opentripplanner.raptor.configure.RaptorTestFactory; +import org.opentripplanner.raptor.direct.api.RaptorDirectTransitRequest; + +/** + * FEATURE UNDER TEST + *

+ * The direct transit search should include trips within the cost limit + */ +public class M04_DirectTransitCostLimit implements RaptorTestConstants { + + private final RaptorService raptorService = RaptorTestFactory.raptorService(); + + /// Expensive trips should be included even if they are not optimal on arrival or departure + @Test + void testIncludeExpensive() { + var data = new TestTransitData(); + data + .withRoute(route(pattern("FAST", STOP_A, STOP_B)).withTimetable(schedule("01:00, 01:10"))) + .withRoute( + route(pattern("SLOW", STOP_A, STOP_B)).withTimetable( + schedule("00:05, 01:05"), + schedule("01:05, 02:05") + ) + ); + + var result = raptorService.findAllDirectTransit(createRequest(), data); + assertEquals( + "A ~ BUS SLOW 0:05 1:05 ~ B [0:05 1:05 1h Tₙ0 C₁4_200]\n" + + "A ~ BUS FAST 1:00 1:10 ~ B [1:00 1:10 10m Tₙ0 C₁1_200]\n" + + "A ~ BUS SLOW 1:05 2:05 ~ B [1:05 2:05 1h Tₙ0 C₁4_200]", + pathsToString(result) + ); + } + + /// Trips with a cost above the limit should be rejected when they are not optimal on arrival or departure + @Test + void testRejectExpensive() { + var data = new TestTransitData(); + data + .withRoute(route(pattern("FAST", STOP_A, STOP_B)).withTimetable(schedule("01:00, 01:10"))) + .withRoute(route(pattern("SLOWER", STOP_A, STOP_B)).withTimetable(schedule("01:00, 01:29"))) + .withRoute(route(pattern("SLOWEST", STOP_A, STOP_B)).withTimetable(schedule("01:00, 01:30"))); + + var result = raptorService.findAllDirectTransit(createRequest(), data); + assertEquals( + "A ~ BUS FAST 1:00 1:10 ~ B [1:00 1:10 10m Tₙ0 C₁1_200]\n" + + "A ~ BUS SLOWER 1:00 1:29 ~ B [1:00 1:29 29m Tₙ0 C₁2_340]", + pathsToString(result) + ); + } + + private RaptorDirectTransitRequest createRequest() { + return RaptorDirectTransitRequest.of() + .withRelaxC1(GeneralizedCostRelaxFunction.of(2)) + .earliestDepartureTime(T00_00) + .searchWindowInSeconds(D24h) + .addAccessPaths(TestAccessEgress.free(STOP_A)) + .addEgressPaths(TestAccessEgress.free(STOP_B)) + .build(); + } +} diff --git a/raptor/src/test/java/org/opentripplanner/raptor/moduletests/package-info.md b/raptor/src/test/java/org/opentripplanner/raptor/moduletests/package-info.md index 2fe372591a1..997c8fe47fd 100644 --- a/raptor/src/test/java/org/opentripplanner/raptor/moduletests/package-info.md +++ b/raptor/src/test/java/org/opentripplanner/raptor/moduletests/package-info.md @@ -24,6 +24,7 @@ group from simple to complex tests (`01` to `99`). - `J` - Via search - `K` - Transit priority - `L` - Time penalty +- `M` - Direct transit search